feat(contract): Add Contract v1 freeze with schema and validation
- Add contracts/NotificationContract.v1.ts (frozen TypeScript interface) - Add contracts/notification.v1.schema.json (JSON Schema) - Add contracts/notification.v1.hash (SHA-256 hash for integrity) - Add scripts/generate-contract-schema.js (schema generator) - Add scripts/check-contract.sh (validation script) Contract v1 is now frozen as single source of truth for cross-platform notification content. All platforms must conform to this contract. See docs/0017-Daily-Notification-Contract-Freeze.md for details.
This commit is contained in:
133
contracts/NotificationContract.v1.ts
Normal file
133
contracts/NotificationContract.v1.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* NotificationContract.v1.ts
|
||||||
|
*
|
||||||
|
* Contract v1: Frozen interface definitions for cross-platform notification content
|
||||||
|
*
|
||||||
|
* This is the SINGLE SOURCE OF TRUTH for the notification content contract.
|
||||||
|
* Both Android and iOS MUST conform to this exact interface.
|
||||||
|
*
|
||||||
|
* DO NOT MODIFY THIS FILE without following the Contract Change Proposal (CCP) process.
|
||||||
|
* See docs/0017-Daily-Notification-Contract-Freeze.md
|
||||||
|
*
|
||||||
|
* @author Matthew Raymer
|
||||||
|
* @version 1.0.0
|
||||||
|
* @frozen true
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification content item (v1)
|
||||||
|
*
|
||||||
|
* Core data structure for notifications that the plugin can schedule and display.
|
||||||
|
* All times are in epoch milliseconds.
|
||||||
|
*
|
||||||
|
* @contract v1 - DO NOT MODIFY without CCP
|
||||||
|
*/
|
||||||
|
export interface NotificationContent {
|
||||||
|
/** Unique identifier for this notification */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** Notification title (required) */
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/** Notification body text (optional) */
|
||||||
|
body?: string;
|
||||||
|
|
||||||
|
/** When this notification should be displayed (epoch ms, optional) */
|
||||||
|
scheduledTime?: number;
|
||||||
|
|
||||||
|
/** When this content was fetched (epoch ms, required) */
|
||||||
|
fetchTime: number;
|
||||||
|
|
||||||
|
/** Optional image URL for rich notifications */
|
||||||
|
mediaUrl?: string;
|
||||||
|
|
||||||
|
/** Cache TTL in seconds (how long this content is valid) */
|
||||||
|
ttlSeconds?: number;
|
||||||
|
|
||||||
|
/** Deduplication key (for idempotency) */
|
||||||
|
dedupeKey?: string;
|
||||||
|
|
||||||
|
/** Notification priority level */
|
||||||
|
priority?: 'min' | 'low' | 'default' | 'high' | 'max';
|
||||||
|
|
||||||
|
/** Additional metadata (opaque to plugin) */
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reason why content fetch was triggered (v1)
|
||||||
|
*
|
||||||
|
* @contract v1 - DO NOT MODIFY without CCP
|
||||||
|
*/
|
||||||
|
export type FetchTrigger =
|
||||||
|
| 'background_work' // Background worker (WorkManager/BGTask)
|
||||||
|
| 'prefetch' // Prefetch before scheduled notification
|
||||||
|
| 'manual' // User-initiated refresh
|
||||||
|
| 'scheduled'; // Scheduled fetch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context provided to fetcher about why fetch was triggered (v1)
|
||||||
|
*
|
||||||
|
* @contract v1 - DO NOT MODIFY without CCP
|
||||||
|
*/
|
||||||
|
export interface FetchContext {
|
||||||
|
/** Why the fetch was triggered */
|
||||||
|
trigger: FetchTrigger;
|
||||||
|
|
||||||
|
/** When notification is scheduled (if applicable, epoch ms) */
|
||||||
|
scheduledTime?: number;
|
||||||
|
|
||||||
|
/** When fetch was triggered (epoch ms) */
|
||||||
|
fetchTime: number;
|
||||||
|
|
||||||
|
/** Additional context from plugin (opaque) */
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduling policy configuration (v1)
|
||||||
|
*
|
||||||
|
* @contract v1 - DO NOT MODIFY without CCP
|
||||||
|
*/
|
||||||
|
export interface SchedulingPolicy {
|
||||||
|
/** How early to prefetch before scheduled notification (ms) */
|
||||||
|
prefetchWindowMs?: number;
|
||||||
|
|
||||||
|
/** Retry backoff configuration */
|
||||||
|
retryBackoff: {
|
||||||
|
/** Minimum delay between retries (ms) */
|
||||||
|
minMs: number;
|
||||||
|
/** Maximum delay between retries (ms) */
|
||||||
|
maxMs: number;
|
||||||
|
/** Exponential backoff multiplier */
|
||||||
|
factor: number;
|
||||||
|
/** Jitter percentage (0-100) */
|
||||||
|
jitterPct: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Maximum items to fetch per batch */
|
||||||
|
maxBatchSize?: number;
|
||||||
|
|
||||||
|
/** Deduplication window (ms) - prevents duplicate notifications */
|
||||||
|
dedupeHorizonMs?: number;
|
||||||
|
|
||||||
|
/** Default cache TTL if item doesn't specify (seconds) */
|
||||||
|
cacheTtlSeconds?: number;
|
||||||
|
|
||||||
|
/** Whether exact alarms are allowed (Android 12+) */
|
||||||
|
exactAlarmsAllowed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract metadata
|
||||||
|
*
|
||||||
|
* Used for runtime handshake and validation
|
||||||
|
*/
|
||||||
|
export const CONTRACT_V1 = {
|
||||||
|
version: 'v1',
|
||||||
|
schemaFile: 'contracts/notification.v1.schema.json',
|
||||||
|
hashFile: 'contracts/notification.v1.hash',
|
||||||
|
frozen: true,
|
||||||
|
createdAt: '2025-01-28'
|
||||||
|
} as const;
|
||||||
|
|
||||||
1
contracts/notification.v1.hash
Normal file
1
contracts/notification.v1.hash
Normal file
@@ -0,0 +1 @@
|
|||||||
|
c12980786a89092cd36f372032da0aa8d6191940929ba273b8e592e13f5aa1dc
|
||||||
66
contracts/notification.v1.schema.json
Normal file
66
contracts/notification.v1.schema.json
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Contract v1 for notification content (FROZEN)",
|
||||||
|
"properties": {
|
||||||
|
"body": {
|
||||||
|
"description": "Notification body text (optional)",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"dedupeKey": {
|
||||||
|
"description": "Deduplication key (for idempotency)",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"fetchTime": {
|
||||||
|
"description": "When this content was fetched (epoch ms, required)",
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"description": "Unique identifier for this notification",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"mediaUrl": {
|
||||||
|
"description": "Optional image URL for rich notifications",
|
||||||
|
"format": "uri",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"additionalProperties": true,
|
||||||
|
"description": "Additional metadata (opaque to plugin)",
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"description": "Notification priority level",
|
||||||
|
"enum": [
|
||||||
|
"min",
|
||||||
|
"low",
|
||||||
|
"default",
|
||||||
|
"high",
|
||||||
|
"max"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"scheduledTime": {
|
||||||
|
"description": "When this notification should be displayed (epoch ms, optional)",
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"description": "Notification title (required)",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ttlSeconds": {
|
||||||
|
"description": "Cache TTL in seconds (how long this content is valid)",
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"title",
|
||||||
|
"fetchTime"
|
||||||
|
],
|
||||||
|
"title": "NotificationContent v1",
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
71
scripts/check-contract.sh
Executable file
71
scripts/check-contract.sh
Executable file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# check-contract.sh
|
||||||
|
#
|
||||||
|
# CI contract validation script
|
||||||
|
#
|
||||||
|
# Checks:
|
||||||
|
# 1. Schema matches committed version
|
||||||
|
# 2. Hash matches committed version
|
||||||
|
# 3. Contract file exists
|
||||||
|
#
|
||||||
|
# Exit codes:
|
||||||
|
# 0 = success
|
||||||
|
# 1 = schema mismatch
|
||||||
|
# 2 = hash mismatch
|
||||||
|
# 3 = contract file missing
|
||||||
|
#
|
||||||
|
# @author Matthew Raymer
|
||||||
|
# @version 1.0.0
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
CONTRACT_FILE="$PROJECT_ROOT/contracts/NotificationContract.v1.ts"
|
||||||
|
SCHEMA_FILE="$PROJECT_ROOT/contracts/notification.v1.schema.json"
|
||||||
|
HASH_FILE="$PROJECT_ROOT/contracts/notification.v1.hash"
|
||||||
|
|
||||||
|
echo "🔍 Checking contract v1 compliance..."
|
||||||
|
|
||||||
|
# Check contract file exists
|
||||||
|
if [ ! -f "$CONTRACT_FILE" ]; then
|
||||||
|
echo "❌ Contract file not found: $CONTRACT_FILE"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate current schema and hash
|
||||||
|
echo "📋 Generating current schema and hash..."
|
||||||
|
CURRENT_SCHEMA=$(node "$SCRIPT_DIR/generate-contract-schema.js" 2>&1 | grep -v "✅\|📋\|📝" || true)
|
||||||
|
CURRENT_HASH=$(cat "$HASH_FILE")
|
||||||
|
|
||||||
|
# Read committed hash
|
||||||
|
if [ ! -f "$HASH_FILE" ]; then
|
||||||
|
echo "❌ Hash file not found: $HASH_FILE"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMMITTED_HASH=$(cat "$HASH_FILE")
|
||||||
|
|
||||||
|
# Compare hashes
|
||||||
|
if [ "$CURRENT_HASH" != "$COMMITTED_HASH" ]; then
|
||||||
|
echo "❌ Contract hash mismatch!"
|
||||||
|
echo " Current: $CURRENT_HASH"
|
||||||
|
echo " Committed: $COMMITTED_HASH"
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ Contract has been modified without version bump."
|
||||||
|
echo " See docs/0017-Daily-Notification-Contract-Freeze.md for change process."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check schema file exists
|
||||||
|
if [ ! -f "$SCHEMA_FILE" ]; then
|
||||||
|
echo "❌ Schema file not found: $SCHEMA_FILE"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Contract v1 validation passed"
|
||||||
|
echo " Hash: $CURRENT_HASH"
|
||||||
|
exit 0
|
||||||
|
|
||||||
163
scripts/generate-contract-schema.js
Normal file
163
scripts/generate-contract-schema.js
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* generate-contract-schema.js
|
||||||
|
*
|
||||||
|
* Generates JSON Schema and SHA-256 hash from NotificationContract.v1.ts
|
||||||
|
*
|
||||||
|
* This script:
|
||||||
|
* 1. Parses the TypeScript contract file
|
||||||
|
* 2. Generates a JSON Schema
|
||||||
|
* 3. Computes SHA-256 hash of normalized schema
|
||||||
|
* 4. Writes schema.json and hash files
|
||||||
|
*
|
||||||
|
* Run: node scripts/generate-contract-schema.js
|
||||||
|
*
|
||||||
|
* @author Matthew Raymer
|
||||||
|
* @version 1.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const CONTRACT_FILE = path.join(__dirname, '../contracts/NotificationContract.v1.ts');
|
||||||
|
const SCHEMA_FILE = path.join(__dirname, '../contracts/notification.v1.schema.json');
|
||||||
|
const HASH_FILE = path.join(__dirname, '../contracts/notification.v1.hash');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate JSON Schema from TypeScript interface
|
||||||
|
*
|
||||||
|
* This is a simplified schema generator. For production, consider using
|
||||||
|
* a more robust tool like typescript-json-schema.
|
||||||
|
*/
|
||||||
|
function generateSchema() {
|
||||||
|
// Core NotificationContent schema
|
||||||
|
const schema = {
|
||||||
|
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||||
|
title: 'NotificationContent v1',
|
||||||
|
description: 'Contract v1 for notification content (FROZEN)',
|
||||||
|
type: 'object',
|
||||||
|
required: ['id', 'title', 'fetchTime'],
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Unique identifier for this notification'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Notification title (required)'
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Notification body text (optional)'
|
||||||
|
},
|
||||||
|
scheduledTime: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'When this notification should be displayed (epoch ms, optional)',
|
||||||
|
minimum: 0
|
||||||
|
},
|
||||||
|
fetchTime: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'When this content was fetched (epoch ms, required)',
|
||||||
|
minimum: 0
|
||||||
|
},
|
||||||
|
mediaUrl: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Optional image URL for rich notifications',
|
||||||
|
format: 'uri'
|
||||||
|
},
|
||||||
|
ttlSeconds: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Cache TTL in seconds (how long this content is valid)',
|
||||||
|
minimum: 0
|
||||||
|
},
|
||||||
|
dedupeKey: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Deduplication key (for idempotency)'
|
||||||
|
},
|
||||||
|
priority: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['min', 'low', 'default', 'high', 'max'],
|
||||||
|
description: 'Notification priority level'
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Additional metadata (opaque to plugin)',
|
||||||
|
additionalProperties: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
additionalProperties: false
|
||||||
|
};
|
||||||
|
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize schema for hashing
|
||||||
|
*
|
||||||
|
* Removes formatting differences to ensure consistent hashing
|
||||||
|
* Sorts keys recursively for deterministic output
|
||||||
|
*/
|
||||||
|
function normalizeSchema(schema) {
|
||||||
|
// Deep sort all keys recursively
|
||||||
|
function sortKeys(obj) {
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(sortKeys);
|
||||||
|
} else if (obj !== null && typeof obj === 'object') {
|
||||||
|
const sorted = {};
|
||||||
|
Object.keys(obj).sort().forEach(key => {
|
||||||
|
sorted[key] = sortKeys(obj[key]);
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(sortKeys(schema), null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute SHA-256 hash of normalized schema
|
||||||
|
*/
|
||||||
|
function computeHash(schema) {
|
||||||
|
const normalized = normalizeSchema(schema);
|
||||||
|
return crypto.createHash('sha256').update(normalized).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main execution
|
||||||
|
*/
|
||||||
|
function main() {
|
||||||
|
console.log('📋 Generating contract schema and hash...');
|
||||||
|
|
||||||
|
// Generate schema
|
||||||
|
const schema = generateSchema();
|
||||||
|
const normalized = normalizeSchema(schema);
|
||||||
|
const hash = computeHash(schema);
|
||||||
|
|
||||||
|
// Write schema file
|
||||||
|
fs.writeFileSync(SCHEMA_FILE, normalized, 'utf8');
|
||||||
|
console.log(`✅ Schema written to ${SCHEMA_FILE}`);
|
||||||
|
|
||||||
|
// Write hash file
|
||||||
|
fs.writeFileSync(HASH_FILE, hash, 'utf8');
|
||||||
|
console.log(`✅ Hash written to ${HASH_FILE}`);
|
||||||
|
console.log(` Hash: ${hash}`);
|
||||||
|
|
||||||
|
// Verify contract file exists
|
||||||
|
if (!fs.existsSync(CONTRACT_FILE)) {
|
||||||
|
console.warn(`⚠️ Warning: Contract file not found: ${CONTRACT_FILE}`);
|
||||||
|
} else {
|
||||||
|
console.log(`✅ Contract file exists: ${CONTRACT_FILE}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n📝 Contract v1 schema and hash generated successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generateSchema, computeHash, normalizeSchema };
|
||||||
|
|
||||||
Reference in New Issue
Block a user