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