Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions SYSTEM_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
241 changes: 241 additions & 0 deletions functions/socialPostContent.js
Original file line number Diff line number Diff line change
@@ -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,
});
Loading