From 520cbf2f23ec6a76e9992a31cc9ee7bdbe0c0b77 Mon Sep 17 00:00:00 2001 From: Rashmi Jadhav Date: Sun, 30 Aug 2026 22:09:02 +0530 Subject: [PATCH] INSTAGRAM-002E: source-only canonical social-post content and payload hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit functions/socialPostContent.js — the caller-side computation the §8.7 reducer and the INSTAGRAM-002B plan builder assume ("payloadHash is computed by the caller"). Defines the officer-editable content record { contentSchemaVersion, caption, mediaReference, altText, scheduledAtEpochSeconds, displayTimeZone, disclosureRequired } with conservative bounds (caption <= 2200 with TAB/LF; one-line alt text <= 1000; opaque media handle, never a URL; UTC epoch second in [0, 4102444800]; IANA-style zone or UTC for display only). validateSocialPost- Content is non-throwing in the §8.7 idiom (frozen projection or 'invalid_content'). computeSocialPostPayloadHash validates then returns a lowercase SHA-256 hex over the fields in fixed order with length-framed, domain-separated encoding, and throws SocialPostContentError on an invalid record so junk can never mint a hash. The digest satisfies the reducer's opaque-identifier shape. This is the one contract in the 002 set that legitimately holds caption/alt text; it still carries no URL, recipient, member, or provider vocabulary. Requires only node:crypto + node:util, imported by no runtime path, defines no Rules or endpoint, reads no clock/randomness/network. functions/socialPostContent.test.js — 43 tests: acceptance, ~25 rejection cases, Proxy/accessor rejection, hash shape/determinism/per-field sensitivity/length- framing, throw-on-invalid, source boundary. SYSTEM_DESIGN.md §8.24. Verification: npm --prefix functions run lint clean; test:run 70 suites / 7612 pass; npm run test:rules 5 suites / 418 pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01GPs7CQHK2h8KqhgKKsXevv --- SYSTEM_DESIGN.md | 6 + functions/socialPostContent.js | 241 ++++++++++++++++++++++++++++ functions/socialPostContent.test.js | 220 +++++++++++++++++++++++++ 3 files changed, 467 insertions(+) create mode 100644 functions/socialPostContent.js create mode 100644 functions/socialPostContent.test.js diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 73b61c9..d5c6240 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -1742,6 +1742,12 @@ This contract invents no policy and duplicates no sibling. It owns the *one-time The module is imported by no runtime or Functions index and requires only `node:util`. It reads no clock, randomness, environment, network, Firestore, or provider service; is imported by no endpoint; stores and logs nothing; holds, reads, or compares no code, hash, or secret; mints no identifier; and grants, sends, or persists nothing beyond the returned verdict. It defines no Firestore schema or Rules, enforces no live rate limit or App Check, and wires into no route or WhatsApp/provider adapter. Source change, tests, merge, Firebase deployment, provider configuration, production data, and live behavior remain separate states; #435 issues no real code and proves none of the external or live states. +### 8.24 Approval-gated social-post canonical content and payload hash — SOURCE ONLY, UNUSED + +INSTAGRAM-002E [#92](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/92) supplies the caller-side computation the §8.7 reducer and §8.7 (INSTAGRAM-002B) plan builder assume: they treat `payloadHash` as opaque and "computed by the caller", and this is that caller. `functions/socialPostContent.js` defines the officer-editable content of a social post and derives the one canonical hash that binds it. `validateSocialPostContent(candidate)` reads the exact revision-1 record `{ contentSchemaVersion, caption, mediaReference, altText, scheduledAtEpochSeconds, displayTimeZone, disclosureRequired }` — a bounded caption (≤ 2200, TAB/LF allowed), an opaque bounded media handle (never a URL), one-line alt text (≤ 1000, no controls), a UTC epoch-second instant in `[0, 4102444800]`, an IANA-style zone name or `UTC` for display only, and a boolean disclosure flag — and returns a frozen canonical projection or the fixed reason `invalid_content`, non-throwing in the §8.7 idiom. `computeSocialPostPayloadHash(candidate)` validates the same record and, only on success, returns a lowercase SHA-256 hex digest over the fields in fixed order with length-framed, domain-separated encoding (`mprc-social-post-payload-sha256` / `mprc.social-post-payload.v1`); on an invalid record it throws `SocialPostContentError` so a bad record can never mint a hash. The digest satisfies the reducer's opaque-identifier shape, so it drops straight into a §8.7 command as `payloadHash` and into a record as `approvedHash`. + +This is the ONE contract in the INSTAGRAM-002 set that legitimately holds a caption and alt text; §8.7 and INSTAGRAM-002B/002C hold only the hash. It still carries no URL, recipient, member, or provider vocabulary — a media reference is an opaque server handle by which the caller looks up the real bytes. It computes no schedule-to-wall-clock conversion (the epoch second is canonical; the zone is presentational), reads no clock, randomness, environment, network, Firestore, or provider, and requires only `node:crypto` and `node:util`. It is imported by no runtime or Functions index, defines no Firestore schema, Rules, or endpoint, and persists and logs nothing. The content store and its Rules, the canonical-content editor and its bounds/escaping policy (caption length, whether a disclosure flag is mandatory), the media upload and byte store, the schedule-to-provider conversion, and the drafting flow from an approved public-event source remain with #92, #93, #121, and their gating owner decisions. Source change, tests, and merge remain separate from Firebase deployment, provider configuration, website publication, and live behavior; INSTAGRAM-002E changes no officer task and proves none of the external or live states. + ## 9. Consistency and failure model Stripe and Firestore cannot share a distributed transaction. Checkout and refunds are therefore explicit sagas: diff --git a/functions/socialPostContent.js b/functions/socialPostContent.js new file mode 100644 index 0000000..65c152d --- /dev/null +++ b/functions/socialPostContent.js @@ -0,0 +1,241 @@ +const { createHash } = require('node:crypto'); +const { types: { isProxy } } = require('node:util'); + +// INSTAGRAM-002E approval-gated social-post canonical content and payload hash. +// +// Pure, source-only, unused. The INSTAGRAM-002A lifecycle reducer (§8.7) and +// the INSTAGRAM-002B plan builder treat `payloadHash` as opaque -- "the caller +// computes it". This contract IS that caller-side computation: it defines the +// officer-editable content of a social post (caption, media reference, alt +// text, UTC schedule + display time zone, disclosure flag), validates it, and +// derives one deterministic canonical hash that binds it. +// +// Safety model: +// * The hash binds content by value without the reducer ever holding it. +// Approving records `approvedHash := payloadHash`; ANY content edit yields +// a different hash, so the reducer's "no publish without a current +// approval" invariant does the rest. +// * Deterministic and unambiguous. Fields are hashed in a fixed order with +// length-framed encoding, so no two distinct records can collide by +// shifting a delimiter, and the same record always yields byte-identical +// output. The digest is a lowercase SHA-256 hex string -- a valid opaque +// identifier for the reducer and plan builder. +// * Content-safe by construction elsewhere. This is the ONE contract in the +// INSTAGRAM-002 set that legitimately holds caption and alt text; every +// sibling holds only its hash. It still carries no URL, recipient, member, +// or provider vocabulary -- a media reference is an opaque server handle, +// not a link. +// * False-negative-safe. A malformed or hostile record fails validation and +// `computeSocialPostPayloadHash` throws rather than hashing junk, so a bad +// record can never mint a hash the reducer would treat as real. +// +// No runtime path imports this module. It reads no clock, randomness, network, +// environment, or provider; it logs nothing and persists nothing. + +const socialPostContentSchemaVersion = 1; + +// A media reference is an opaque, bounded, url-safe server handle by which the +// caller looks up the real media bytes in its own store. Never a URL. +const MEDIA_REFERENCE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + +// IANA-style zone name (Area/Location, optionally with a third segment), or the +// literal UTC. Presentational only; the canonical instant is the epoch second. +const TIME_ZONE_PATTERN = /^(?:UTC|[A-Za-z][A-Za-z0-9_+-]*(?:\/[A-Za-z0-9_+-]+){1,2})$/; + +// C0 controls, DEL, and C1 controls are disallowed in text fields. The caption +// keeps TAB (9) and LF (10); alt text is one line and keeps neither. +function hasDisallowedControlChar(value, allowTabAndNewline) { + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + const isControl = code <= 0x1f || (code >= 0x7f && code <= 0x9f); + if (!isControl) continue; + if (allowTabAndNewline && (code === 0x09 || code === 0x0a)) continue; + return true; + } + return false; +} + +// Conservative bounds. Caption allows newlines and tabs; alt text is one line. +const LIMITS = Object.freeze({ + maxCaptionLength: 2200, + maxAltTextLength: 1000, + maxTimeZoneLength: 64, + minScheduledAtEpochSeconds: 0, + maxScheduledAtEpochSeconds: 4102444800, // 2100-01-01T00:00:00Z +}); + +const EXPECTED_CONTENT_KEYS = Object.freeze([ + 'contentSchemaVersion', + 'caption', + 'mediaReference', + 'altText', + 'scheduledAtEpochSeconds', + 'displayTimeZone', + 'disclosureRequired', +]); + +const FIXED_REASON = 'invalid_content'; +const CONTENT_ERROR_MESSAGE = 'Social post content is invalid.'; + +const HASH_MAGIC = 'mprc-social-post-payload-sha256'; +const HASH_DOMAIN = 'mprc.social-post-payload.v1'; + +class SocialPostContentError extends Error { + constructor() { + super(CONTENT_ERROR_MESSAGE); + this.name = 'SocialPostContentError'; + this.reason = FIXED_REASON; + Object.freeze(this); + } +} + +function frozenReasons(reasons) { + return Object.freeze([...new Set(reasons)].sort()); +} + +// Strict own-data reader: rejects a proxy, a non-plain prototype, an accessor, +// a non-enumerable own field, an inherited field, and any extra key. Never +// invokes a getter. Mirrors the reader in socialPostState.js. +function safeOwnData(value, maximumEntries) { + if (value === null || typeof value !== 'object' || isProxy(value)) return null; + + let prototype; + let keys; + try { + prototype = Object.getPrototypeOf(value); + keys = Reflect.ownKeys(value); + } catch { + return null; + } + if (prototype !== Object.prototype || keys.length > maximumEntries) return null; + + const entries = new Map(); + for (const key of keys) { + if (typeof key !== 'string') return null; + let descriptor; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + return null; + } + if (!descriptor + || descriptor.enumerable !== true + || !Object.prototype.hasOwnProperty.call(descriptor, 'value') + || descriptor.get !== undefined + || descriptor.set !== undefined) { + return null; + } + entries.set(key, descriptor.value); + } + + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) return null; + } + return entries; +} + +function readExactContent(candidate) { + const entries = safeOwnData(candidate, EXPECTED_CONTENT_KEYS.length); + if (!entries + || entries.size !== EXPECTED_CONTENT_KEYS.length + || !EXPECTED_CONTENT_KEYS.every((key) => entries.has(key))) { + return null; + } + return { + contentSchemaVersion: entries.get('contentSchemaVersion'), + caption: entries.get('caption'), + mediaReference: entries.get('mediaReference'), + altText: entries.get('altText'), + scheduledAtEpochSeconds: entries.get('scheduledAtEpochSeconds'), + displayTimeZone: entries.get('displayTimeZone'), + disclosureRequired: entries.get('disclosureRequired'), + }; +} + +function isBoundedText(value, maxLength, allowTabAndNewline) { + return typeof value === 'string' + && value.length >= 1 + && value.length <= maxLength + && !hasDisallowedControlChar(value, allowTabAndNewline); +} + +function validationResult(accepted, status, reasons, projection) { + return Object.freeze({ + accepted, + status, + reasons: frozenReasons(reasons), + projection, + }); +} + +// Validate the officer-editable content record. Non-throwing: returns a frozen +// canonical projection or a fixed rejection reason. +function validateSocialPostContent(candidate) { + const record = readExactContent(candidate); + if (!record) { + return validationResult(false, 'rejected', [FIXED_REASON], null); + } + + if (record.contentSchemaVersion !== socialPostContentSchemaVersion + || !isBoundedText(record.caption, LIMITS.maxCaptionLength, true) + || typeof record.mediaReference !== 'string' + || !MEDIA_REFERENCE_PATTERN.test(record.mediaReference) + || !isBoundedText(record.altText, LIMITS.maxAltTextLength, false) + || !Number.isSafeInteger(record.scheduledAtEpochSeconds) + || Object.is(record.scheduledAtEpochSeconds, -0) + || record.scheduledAtEpochSeconds < LIMITS.minScheduledAtEpochSeconds + || record.scheduledAtEpochSeconds > LIMITS.maxScheduledAtEpochSeconds + || typeof record.displayTimeZone !== 'string' + || record.displayTimeZone.length > LIMITS.maxTimeZoneLength + || !TIME_ZONE_PATTERN.test(record.displayTimeZone) + || typeof record.disclosureRequired !== 'boolean') { + return validationResult(false, 'rejected', [FIXED_REASON], null); + } + + return validationResult(true, 'valid', [], Object.freeze({ ...record })); +} + +function unsignedLength(length) { + const buffer = Buffer.allocUnsafe(4); + buffer.writeUInt32BE(length, 0); + return buffer; +} + +function updateLengthFramed(hash, value) { + const bytes = Buffer.from(value, 'utf8'); + hash.update(unsignedLength(bytes.length)); + hash.update(bytes); +} + +// Derive the canonical payload hash. Throws SocialPostContentError on an +// invalid record so a bad record can never mint a hash. The digest is a +// lowercase SHA-256 hex string: a valid opaque identifier for the §8.7 reducer. +function computeSocialPostPayloadHash(candidate) { + const validation = validateSocialPostContent(candidate); + if (!validation.accepted) throw new SocialPostContentError(); + const record = validation.projection; + + const hash = createHash('sha256'); + updateLengthFramed(hash, HASH_MAGIC); + updateLengthFramed(hash, HASH_DOMAIN); + for (const [name, value] of [ + ['contentSchemaVersion', String(record.contentSchemaVersion)], + ['caption', record.caption], + ['mediaReference', record.mediaReference], + ['altText', record.altText], + ['scheduledAtEpochSeconds', String(record.scheduledAtEpochSeconds)], + ['displayTimeZone', record.displayTimeZone], + ['disclosureRequired', record.disclosureRequired ? '1' : '0'], + ]) { + updateLengthFramed(hash, name); + updateLengthFramed(hash, value); + } + return hash.digest('hex'); +} + +module.exports = Object.freeze({ + socialPostContentSchemaVersion, + SocialPostContentError, + validateSocialPostContent, + computeSocialPostPayloadHash, +}); diff --git a/functions/socialPostContent.test.js b/functions/socialPostContent.test.js new file mode 100644 index 0000000..2eb2a37 --- /dev/null +++ b/functions/socialPostContent.test.js @@ -0,0 +1,220 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +const { + socialPostContentSchemaVersion, + SocialPostContentError, + validateSocialPostContent, + computeSocialPostPayloadHash, +} = require('./socialPostContent'); + +// The reducer's opaque-identifier shape (socialPostState.js). The derived hash +// must satisfy it so the reducer and plan builder accept it as a payloadHash. +const OPAQUE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + +const HOSTILE_CANARY = 'caption=leak; media=https://cdn.example/signed?sig=abc; contact=+12025550123'; + +function content(overrides = {}) { + return { + contentSchemaVersion: 1, + caption: 'Saturday long run from the boathouse. All paces welcome.', + mediaReference: 'media_2026_summer.0007', + altText: 'Runners gathered on a riverside path at sunrise.', + scheduledAtEpochSeconds: 1_800_000_000, + displayTimeZone: 'America/Los_Angeles', + disclosureRequired: false, + ...overrides, + }; +} + +function expectValid(candidate) { + const result = validateSocialPostContent(candidate); + expect(result.accepted).toBe(true); + expect(result.status).toBe('valid'); + expect(result.reasons).toEqual([]); + expect(Object.isFrozen(result.projection)).toBe(true); + return result.projection; +} + +function expectInvalid(candidate) { + const result = validateSocialPostContent(candidate); + expect(result.accepted).toBe(false); + expect(result.status).toBe('rejected'); + expect(result.reasons).toEqual(['invalid_content']); + expect(result.projection).toBeNull(); + expect(JSON.stringify(result)).not.toContain(HOSTILE_CANARY); +} + +describe('versioned surface', () => { + test('schema version is 1 and the export is frozen', () => { + expect(socialPostContentSchemaVersion).toBe(1); + expect(Object.isFrozen(require('./socialPostContent'))).toBe(true); + }); +}); + +describe('validateSocialPostContent — acceptance', () => { + test('a canonical record validates and projects an equal frozen copy', () => { + expect(expectValid(content())).toEqual(content()); + }); + + test('caption may carry tabs and newlines', () => { + expectValid(content({ caption: 'line one\nline two\twith a tab' })); + }); + + test('displayTimeZone accepts UTC and a three-segment zone', () => { + expectValid(content({ displayTimeZone: 'UTC' })); + expectValid(content({ displayTimeZone: 'America/Argentina/Buenos_Aires' })); + }); + + test('the schedule bounds are inclusive at both ends', () => { + expectValid(content({ scheduledAtEpochSeconds: 0 })); + expectValid(content({ scheduledAtEpochSeconds: 4_102_444_800 })); + }); +}); + +describe('validateSocialPostContent — rejection', () => { + const BAD = [ + ['not an object', HOSTILE_CANARY], + ['null', null], + ['an array', [content()]], + ['wrong schema version', content({ contentSchemaVersion: 2 })], + ['empty caption', content({ caption: '' })], + ['caption over the limit', content({ caption: 'x'.repeat(2201) })], + ['caption with a NUL', content({ caption: 'hi\x00there' })], + ['caption with a C1 control', content({ caption: 'hi\x85there' })], + ['non-string caption', content({ caption: 42 })], + ['media reference with a space', content({ mediaReference: 'media 7' })], + ['media reference that looks like a URL', content({ mediaReference: 'https://x/y' })], + ['empty media reference', content({ mediaReference: '' })], + ['empty alt text', content({ altText: '' })], + ['alt text with a newline', content({ altText: 'first line\nsecond line' })], + ['alt text over the limit', content({ altText: 'x'.repeat(1001) })], + ['fractional schedule', content({ scheduledAtEpochSeconds: 1.5 })], + ['negative schedule', content({ scheduledAtEpochSeconds: -1 })], + ['negative zero schedule', content({ scheduledAtEpochSeconds: -0 })], + ['schedule past the year 2100', content({ scheduledAtEpochSeconds: 4_102_444_801 })], + ['string schedule', content({ scheduledAtEpochSeconds: '1800000000' })], + ['unknown time zone shape', content({ displayTimeZone: 'Pacific Time' })], + ['single-segment time zone', content({ displayTimeZone: 'America' })], + ['over-long time zone', content({ displayTimeZone: `Etc/${'X'.repeat(64)}` })], + ['non-boolean disclosure flag', content({ disclosureRequired: 'no' })], + ['extra key', { ...content(), leaked: HOSTILE_CANARY }], + ['missing key', (() => { const c = content(); delete c.altText; return c; })()], + ]; + + test.each(BAD)('rejects %s', (_label, candidate) => { + expectInvalid(candidate); + }); + + test('a Proxy is rejected without invoking a trap', () => { + let trapped = false; + const proxied = new Proxy(content(), { + get(target, prop, receiver) { trapped = true; return Reflect.get(target, prop, receiver); }, + }); + expect(validateSocialPostContent(proxied).accepted).toBe(false); + expect(trapped).toBe(false); + }); + + test('an accessor-backed field is rejected without invoking the getter', () => { + let invoked = false; + const hostile = content(); + Object.defineProperty(hostile, 'caption', { + enumerable: true, + get() { invoked = true; return 'clean'; }, + }); + expect(validateSocialPostContent(hostile).accepted).toBe(false); + expect(invoked).toBe(false); + }); +}); + +describe('computeSocialPostPayloadHash', () => { + test('produces a lowercase sha-256 hex that is a valid opaque identifier', () => { + const hash = computeSocialPostPayloadHash(content()); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + expect(hash).toMatch(OPAQUE_IDENTIFIER_PATTERN); + }); + + test('is deterministic for an equal record', () => { + expect(computeSocialPostPayloadHash(content())).toBe(computeSocialPostPayloadHash(content())); + }); + + test('every field participates in the digest', () => { + const base = computeSocialPostPayloadHash(content()); + const changes = [ + { caption: 'A different caption entirely.' }, + { mediaReference: 'media_2026_summer.0008' }, + { altText: 'A different description of the scene.' }, + { scheduledAtEpochSeconds: 1_800_000_001 }, + { displayTimeZone: 'UTC' }, + { disclosureRequired: true }, + ]; + for (const change of changes) { + expect(computeSocialPostPayloadHash(content(change))).not.toBe(base); + } + }); + + test('length framing prevents a field-boundary collision', () => { + const a = computeSocialPostPayloadHash(content({ caption: 'ab', altText: 'cd' })); + const b = computeSocialPostPayloadHash(content({ caption: 'abc', altText: 'd' })); + expect(a).not.toBe(b); + }); + + test('throws SocialPostContentError on an invalid record and never echoes it', () => { + for (const bad of [ + null, + HOSTILE_CANARY, + content({ caption: '' }), + content({ mediaReference: 'https://x/y' }), + { ...content(), leaked: HOSTILE_CANARY }, + ]) { + let thrown; + try { + computeSocialPostPayloadHash(bad); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(SocialPostContentError); + expect(thrown.reason).toBe('invalid_content'); + expect(String(thrown.message)).not.toContain(HOSTILE_CANARY); + } + }); +}); + +describe('source boundary — pure, unused, provider-neutral', () => { + const source = fs.readFileSync(path.join(__dirname, 'socialPostContent.js'), 'utf8'); + function codeOnly(text) { + return text + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/(^|[^:])\/\/[^\n]*/gm, '$1'); + } + const code = codeOnly(source); + + test('requires only node:crypto and node:util', () => { + const requires = [...code.matchAll(/require\(([^)]*)\)/g)].map((m) => m[1].trim()); + expect(requires).toEqual(["'node:crypto'", "'node:util'"]); + }); + + test('is not imported by the functions runtime entry point', () => { + const index = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8'); + expect(index).not.toContain('socialPostContent'); + }); + + test('reads no clock, randomness, environment, network, or provider surface', () => { + for (const forbidden of [ + /process\.env/, /Date\.now/, /new Date/, /Math\.random/, /console\./, + /fetch\(/, /https?:/, /firebase/i, /firestore/i, /stripe/i, + ]) { + expect(code).not.toMatch(forbidden); + } + }); + + test('the executable surface names no concrete social provider', () => { + expect(code).not.toMatch(/instagram/i); + expect(code).not.toMatch(/facebook/i); + }); + + test('the digest is domain-separated', () => { + expect(source).toContain('mprc-social-post-payload-sha256'); + expect(source).toContain('mprc.social-post-payload.v1'); + }); +});