@ttt-productions/ttt-core 0.78.22 → 0.78.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -57,4 +57,12 @@ export declare function chatMessageRangeResourceKey(channelId: string, messageSe
|
|
|
57
57
|
export declare function channelResourceKey(channelId: string): ResourceKey;
|
|
58
58
|
/** Account target = uid. */
|
|
59
59
|
export declare function accountResourceKey(uid: string): ResourceKey;
|
|
60
|
+
/**
|
|
61
|
+
* The composite doc id for a `safetyResourceCommands` row: `resourceKeyHash__commandId`.
|
|
62
|
+
* ONE canonical derivation across the whole safety runtime — both the NCII removal and the
|
|
63
|
+
* CSAM quarantine sagas write and read this collection, and a drift between two hand-written
|
|
64
|
+
* joiners would target different docs, breaking command idempotency on a safety-critical path.
|
|
65
|
+
* `PATH_BUILDERS.safetyResourceCommand(commandDocId)` consumes the result of this function.
|
|
66
|
+
*/
|
|
67
|
+
export declare function safetyResourceCommandDocId(resourceKeyHash: string, commandId: string): string;
|
|
60
68
|
//# sourceMappingURL=resource-keys.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resource-keys.d.ts","sourceRoot":"","sources":["../../src/safety/resource-keys.ts"],"names":[],"mappings":"AAsCA,OAAO,KAAK,EAAE,UAAU,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AASlF,mFAAmF;AACnF,MAAM,WAAW,WAAW;IAC1B,sEAAsE;IACtE,QAAQ,CAAC,YAAY,EAAE,sBAAsB,CAAC;IAC9C;2EACuE;IACvE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,4DAA4D;IAC5D,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAC;IACtC,6EAA6E;IAC7E,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;CAClC;AAED,2EAA2E;AAC3E,wBAAgB,eAAe,CAAC,oBAAoB,EAAE,MAAM,GAAG,MAAM,CAEpE;AAiBD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,WAAW,CAE7F;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,GAAG,MAAM,GAC1B,WAAW,CAEb;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,IAAI,CAAC,UAAU,EAAE,cAAc,GAAG,eAAe,CAAC,GAAG,WAAW,EAAE,CAS/G;AAED;wFACwF;AACxF,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,WAAW,CAEvE;AAED;;;;;GAKG;AACH,wBAAgB,2BAA2B,CACzC,SAAS,EAAE,MAAM,EACjB,iBAAiB,EAAE,MAAM,GAAG,MAAM,GACjC,WAAW,CAOb;AAED,kCAAkC;AAClC,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,WAAW,CAEjE;AAED,4BAA4B;AAC5B,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,WAAW,CAE3D"}
|
|
1
|
+
{"version":3,"file":"resource-keys.d.ts","sourceRoot":"","sources":["../../src/safety/resource-keys.ts"],"names":[],"mappings":"AAsCA,OAAO,KAAK,EAAE,UAAU,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AASlF,mFAAmF;AACnF,MAAM,WAAW,WAAW;IAC1B,sEAAsE;IACtE,QAAQ,CAAC,YAAY,EAAE,sBAAsB,CAAC;IAC9C;2EACuE;IACvE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,4DAA4D;IAC5D,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAC;IACtC,6EAA6E;IAC7E,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;CAClC;AAED,2EAA2E;AAC3E,wBAAgB,eAAe,CAAC,oBAAoB,EAAE,MAAM,GAAG,MAAM,CAEpE;AAiBD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,WAAW,CAE7F;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,GAAG,MAAM,GAC1B,WAAW,CAEb;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,IAAI,CAAC,UAAU,EAAE,cAAc,GAAG,eAAe,CAAC,GAAG,WAAW,EAAE,CAS/G;AAED;wFACwF;AACxF,wBAAgB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,WAAW,CAEvE;AAED;;;;;GAKG;AACH,wBAAgB,2BAA2B,CACzC,SAAS,EAAE,MAAM,EACjB,iBAAiB,EAAE,MAAM,GAAG,MAAM,GACjC,WAAW,CAOb;AAED,kCAAkC;AAClC,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,WAAW,CAEjE;AAED,4BAA4B;AAC5B,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,WAAW,CAE3D;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,eAAe,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAE7F"}
|
|
@@ -119,4 +119,14 @@ export function channelResourceKey(channelId) {
|
|
|
119
119
|
export function accountResourceKey(uid) {
|
|
120
120
|
return buildKey('account', uid, uid);
|
|
121
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* The composite doc id for a `safetyResourceCommands` row: `resourceKeyHash__commandId`.
|
|
124
|
+
* ONE canonical derivation across the whole safety runtime — both the NCII removal and the
|
|
125
|
+
* CSAM quarantine sagas write and read this collection, and a drift between two hand-written
|
|
126
|
+
* joiners would target different docs, breaking command idempotency on a safety-critical path.
|
|
127
|
+
* `PATH_BUILDERS.safetyResourceCommand(commandDocId)` consumes the result of this function.
|
|
128
|
+
*/
|
|
129
|
+
export function safetyResourceCommandDocId(resourceKeyHash, commandId) {
|
|
130
|
+
return `${resourceKeyHash}__${commandId}`;
|
|
131
|
+
}
|
|
122
132
|
//# sourceMappingURL=resource-keys.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resource-keys.js","sourceRoot":"","sources":["../../src/safety/resource-keys.ts"],"names":[],"mappings":"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","sourcesContent":["// Canonical safety-hold resource keys (Trust & Safety IMPLEMENTATION_PLAN Appendix A3).\n//\n// A safety hold is keyed by the PHYSICAL resource it protects, not by the case or\n// the request that created it. The `canonicalResourceKey` is a deterministic string\n// built from exactly the identity fields that pin a physical resource:\n//\n// - a Firestore content target = document path + immutable revision/generation\n// - a storage object = bucket + key + generation\n// - a media target = mediaAsset identity (and, incident-wide, its\n// MediaOriginLineageV1.rootIngestId)\n// - a chat message range = channelId + sequence/epoch boundary\n// - a channel = channelId\n// - an account = uid\n//\n// `resourceKeyHash = sha256(canonicalResourceKey)` is the Firestore doc id for both\n// `safetyHoldResources/{resourceKeyHash}` (the O(1) per-flag counter aggregate) and\n// the `{caseId}__{resourceKeyHash}` ref. THE SAME PHYSICAL RESOURCE MUST ALWAYS HASH\n// IDENTICALLY — that is the whole correctness story: every hold over the same bytes\n// joins the same aggregate, and every destructive guard consults the same doc.\n//\n// This is the ONE canonical declaration (ARCH-102). Both TTT app TS projects (the Cloud\n// Functions graph AND the App Hosting route graph) import these builders from ttt-core\n// rather than hand-mirroring the format — a re-key would otherwise drift the two runtimes\n// apart. node:crypto sha256 keeps it pure + server-safe (this module lives behind the\n// `./safety` entry, never on the main barrel, so the frontend bundle never loads it).\n//\n// Determinism rules (do not change without a key-version bump):\n// - Format is `v1` + a type tag + the identity fields, joined by the unit-separator\n// control char (\\x1f), which cannot appear in a Firestore path, bucket name, key,\n// uid, or channel id. No JSON (key ordering / whitespace is not load-bearing here;\n// a flat separator-joined string is stable by construction).\n// - The type tag prevents cross-type collisions (a contentDoc whose path happens to\n// equal a storage key never shares a hash with that storage object).\n// - A bump to the format string ('v1') is a hold-key migration and is NOT backward\n// compatible — every existing ref/aggregate would re-key. Treat it as a breaking\n// change to the entire hold authority.\n\nimport { createHash } from 'node:crypto';\nimport type { MediaAsset, SafetyHoldResourceType } from '../doc-schemas/index.js';\n\n/** Format/version tag for every canonical key. Bumping this re-keys ALL holds. */\nconst KEY_FORMAT = 'safetyHoldKey/v1';\n\n/** Unit separator — illegal in every identity field we join, so it is an unambiguous\n * field delimiter that never appears inside a path, key, uid, or channel id. */\nconst SEP = '\\x1f';\n\n/** A canonical resource key paired with its sha256 hash (the Firestore doc id). */\nexport interface ResourceKey {\n /** The hold `resourceType` this key belongs to (Appendix A3 enum). */\n readonly resourceType: SafetyHoldResourceType;\n /** A natural identifier for the resource, stored on the ref as `resourceId`\n * (human-debuggable; NOT used for hashing — the canonical key is). */\n readonly resourceId: string;\n /** The deterministic canonical key string (Appendix A3). */\n readonly canonicalResourceKey: string;\n /** `sha256(canonicalResourceKey)` hex — the `safetyHoldResources` doc id. */\n readonly resourceKeyHash: string;\n}\n\n/** sha256-hex of a canonical key string. The single hashing chokepoint. */\nexport function resourceKeyHash(canonicalResourceKey: string): string {\n return createHash('sha256').update(canonicalResourceKey, 'utf8').digest('hex');\n}\n\n/** Build a {canonicalResourceKey, resourceKeyHash} from a type tag + identity parts. */\nfunction buildKey(\n resourceType: SafetyHoldResourceType,\n resourceId: string,\n ...parts: string[]\n): ResourceKey {\n const canonicalResourceKey = [KEY_FORMAT, resourceType, ...parts].join(SEP);\n return {\n resourceType,\n resourceId,\n canonicalResourceKey,\n resourceKeyHash: resourceKeyHash(canonicalResourceKey),\n };\n}\n\n/**\n * Firestore content document target = document path + immutable revision.\n * `docPath` must be the canonical full path (e.g. via `toPath(PATH_BUILDERS.x(...))`),\n * NOT a client-supplied path. `revision` pins a mutable doc to a specific version so a\n * post-hold edit cannot slip the bytes out from under the hold; pass the revision/\n * version the report froze.\n */\nexport function contentDocResourceKey(docPath: string, revision: string | number): ResourceKey {\n return buildKey('contentDoc', docPath, docPath, String(revision));\n}\n\n/**\n * Storage object target = bucket + key + generation. `generation` pins the exact\n * object version (a re-upload to the same key gets a new generation and is a different\n * physical resource). Pass the generation captured when the hold was placed.\n */\nexport function storageObjectResourceKey(\n bucket: string,\n key: string,\n generation: string | number,\n): ResourceKey {\n return buildKey('storageObject', `${bucket}/${key}`, bucket, key, String(generation));\n}\n\n/**\n * Media target. Returns keys for BOTH:\n * 1. the specific `mediaAssetId` (this exact asset/copy), AND\n * 2. its `originLineage.rootIngestId` — when present — as a SEPARATE `mediaAsset`\n * key so a hold can block the ENTIRE incident lineage (every copy/variant that\n * inherited the same root ingest), per Appendix A0/A3 incident-wide blocking.\n *\n * The caller decides which key(s) to place a hold on: a single-asset takedown holds\n * key[0]; an incident-wide block over a CSAM/NCII lineage holds the rootIngest key so\n * every existing AND future copy of the same root is denied. Both keys are `mediaAsset`\n * resourceType (rootIngest is a media identity, not a distinct type — A3 has no\n * separate `rootIngest` type; it is modeled as a mediaAsset key over the root id).\n *\n * Order is stable: index 0 = the asset itself; index 1 (if any) = the rootIngest.\n */\nexport function mediaAssetResourceKeys(asset: Pick<MediaAsset, 'mediaAssetId' | 'originLineage'>): ResourceKey[] {\n const keys: ResourceKey[] = [\n buildKey('mediaAsset', asset.mediaAssetId, 'asset', asset.mediaAssetId),\n ];\n const rootIngestId = asset.originLineage?.rootIngestId;\n if (rootIngestId) {\n keys.push(buildKey('mediaAsset', rootIngestId, 'rootIngest', rootIngestId));\n }\n return keys;\n}\n\n/** The mediaAsset key for a bare rootIngestId (no asset doc in hand) — same canonical\n * shape as `mediaAssetResourceKeys(...)`'s index-1 entry, so it hashes identically. */\nexport function rootIngestResourceKey(rootIngestId: string): ResourceKey {\n return buildKey('mediaAsset', rootIngestId, 'rootIngest', rootIngestId);\n}\n\n/**\n * Chat message-range target = channelId + a sequence/epoch boundary identifying the\n * span of messages held. `messageSeqOrEpoch` is the caller's range identifier (e.g. a\n * sequence number or epoch marker); the SAME range over the same channel must always\n * be passed in the same canonical form by the caller.\n */\nexport function chatMessageRangeResourceKey(\n channelId: string,\n messageSeqOrEpoch: string | number,\n): ResourceKey {\n return buildKey(\n 'chatMessageRange',\n `${channelId}#${messageSeqOrEpoch}`,\n channelId,\n String(messageSeqOrEpoch),\n );\n}\n\n/** Channel target = channelId. */\nexport function channelResourceKey(channelId: string): ResourceKey {\n return buildKey('channel', channelId, channelId);\n}\n\n/** Account target = uid. */\nexport function accountResourceKey(uid: string): ResourceKey {\n return buildKey('account', uid, uid);\n}\n"]}
|
|
1
|
+
{"version":3,"file":"resource-keys.js","sourceRoot":"","sources":["../../src/safety/resource-keys.ts"],"names":[],"mappings":"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","sourcesContent":["// Canonical safety-hold resource keys (Trust & Safety IMPLEMENTATION_PLAN Appendix A3).\n//\n// A safety hold is keyed by the PHYSICAL resource it protects, not by the case or\n// the request that created it. The `canonicalResourceKey` is a deterministic string\n// built from exactly the identity fields that pin a physical resource:\n//\n// - a Firestore content target = document path + immutable revision/generation\n// - a storage object = bucket + key + generation\n// - a media target = mediaAsset identity (and, incident-wide, its\n// MediaOriginLineageV1.rootIngestId)\n// - a chat message range = channelId + sequence/epoch boundary\n// - a channel = channelId\n// - an account = uid\n//\n// `resourceKeyHash = sha256(canonicalResourceKey)` is the Firestore doc id for both\n// `safetyHoldResources/{resourceKeyHash}` (the O(1) per-flag counter aggregate) and\n// the `{caseId}__{resourceKeyHash}` ref. THE SAME PHYSICAL RESOURCE MUST ALWAYS HASH\n// IDENTICALLY — that is the whole correctness story: every hold over the same bytes\n// joins the same aggregate, and every destructive guard consults the same doc.\n//\n// This is the ONE canonical declaration (ARCH-102). Both TTT app TS projects (the Cloud\n// Functions graph AND the App Hosting route graph) import these builders from ttt-core\n// rather than hand-mirroring the format — a re-key would otherwise drift the two runtimes\n// apart. node:crypto sha256 keeps it pure + server-safe (this module lives behind the\n// `./safety` entry, never on the main barrel, so the frontend bundle never loads it).\n//\n// Determinism rules (do not change without a key-version bump):\n// - Format is `v1` + a type tag + the identity fields, joined by the unit-separator\n// control char (\\x1f), which cannot appear in a Firestore path, bucket name, key,\n// uid, or channel id. No JSON (key ordering / whitespace is not load-bearing here;\n// a flat separator-joined string is stable by construction).\n// - The type tag prevents cross-type collisions (a contentDoc whose path happens to\n// equal a storage key never shares a hash with that storage object).\n// - A bump to the format string ('v1') is a hold-key migration and is NOT backward\n// compatible — every existing ref/aggregate would re-key. Treat it as a breaking\n// change to the entire hold authority.\n\nimport { createHash } from 'node:crypto';\nimport type { MediaAsset, SafetyHoldResourceType } from '../doc-schemas/index.js';\n\n/** Format/version tag for every canonical key. Bumping this re-keys ALL holds. */\nconst KEY_FORMAT = 'safetyHoldKey/v1';\n\n/** Unit separator — illegal in every identity field we join, so it is an unambiguous\n * field delimiter that never appears inside a path, key, uid, or channel id. */\nconst SEP = '\\x1f';\n\n/** A canonical resource key paired with its sha256 hash (the Firestore doc id). */\nexport interface ResourceKey {\n /** The hold `resourceType` this key belongs to (Appendix A3 enum). */\n readonly resourceType: SafetyHoldResourceType;\n /** A natural identifier for the resource, stored on the ref as `resourceId`\n * (human-debuggable; NOT used for hashing — the canonical key is). */\n readonly resourceId: string;\n /** The deterministic canonical key string (Appendix A3). */\n readonly canonicalResourceKey: string;\n /** `sha256(canonicalResourceKey)` hex — the `safetyHoldResources` doc id. */\n readonly resourceKeyHash: string;\n}\n\n/** sha256-hex of a canonical key string. The single hashing chokepoint. */\nexport function resourceKeyHash(canonicalResourceKey: string): string {\n return createHash('sha256').update(canonicalResourceKey, 'utf8').digest('hex');\n}\n\n/** Build a {canonicalResourceKey, resourceKeyHash} from a type tag + identity parts. */\nfunction buildKey(\n resourceType: SafetyHoldResourceType,\n resourceId: string,\n ...parts: string[]\n): ResourceKey {\n const canonicalResourceKey = [KEY_FORMAT, resourceType, ...parts].join(SEP);\n return {\n resourceType,\n resourceId,\n canonicalResourceKey,\n resourceKeyHash: resourceKeyHash(canonicalResourceKey),\n };\n}\n\n/**\n * Firestore content document target = document path + immutable revision.\n * `docPath` must be the canonical full path (e.g. via `toPath(PATH_BUILDERS.x(...))`),\n * NOT a client-supplied path. `revision` pins a mutable doc to a specific version so a\n * post-hold edit cannot slip the bytes out from under the hold; pass the revision/\n * version the report froze.\n */\nexport function contentDocResourceKey(docPath: string, revision: string | number): ResourceKey {\n return buildKey('contentDoc', docPath, docPath, String(revision));\n}\n\n/**\n * Storage object target = bucket + key + generation. `generation` pins the exact\n * object version (a re-upload to the same key gets a new generation and is a different\n * physical resource). Pass the generation captured when the hold was placed.\n */\nexport function storageObjectResourceKey(\n bucket: string,\n key: string,\n generation: string | number,\n): ResourceKey {\n return buildKey('storageObject', `${bucket}/${key}`, bucket, key, String(generation));\n}\n\n/**\n * Media target. Returns keys for BOTH:\n * 1. the specific `mediaAssetId` (this exact asset/copy), AND\n * 2. its `originLineage.rootIngestId` — when present — as a SEPARATE `mediaAsset`\n * key so a hold can block the ENTIRE incident lineage (every copy/variant that\n * inherited the same root ingest), per Appendix A0/A3 incident-wide blocking.\n *\n * The caller decides which key(s) to place a hold on: a single-asset takedown holds\n * key[0]; an incident-wide block over a CSAM/NCII lineage holds the rootIngest key so\n * every existing AND future copy of the same root is denied. Both keys are `mediaAsset`\n * resourceType (rootIngest is a media identity, not a distinct type — A3 has no\n * separate `rootIngest` type; it is modeled as a mediaAsset key over the root id).\n *\n * Order is stable: index 0 = the asset itself; index 1 (if any) = the rootIngest.\n */\nexport function mediaAssetResourceKeys(asset: Pick<MediaAsset, 'mediaAssetId' | 'originLineage'>): ResourceKey[] {\n const keys: ResourceKey[] = [\n buildKey('mediaAsset', asset.mediaAssetId, 'asset', asset.mediaAssetId),\n ];\n const rootIngestId = asset.originLineage?.rootIngestId;\n if (rootIngestId) {\n keys.push(buildKey('mediaAsset', rootIngestId, 'rootIngest', rootIngestId));\n }\n return keys;\n}\n\n/** The mediaAsset key for a bare rootIngestId (no asset doc in hand) — same canonical\n * shape as `mediaAssetResourceKeys(...)`'s index-1 entry, so it hashes identically. */\nexport function rootIngestResourceKey(rootIngestId: string): ResourceKey {\n return buildKey('mediaAsset', rootIngestId, 'rootIngest', rootIngestId);\n}\n\n/**\n * Chat message-range target = channelId + a sequence/epoch boundary identifying the\n * span of messages held. `messageSeqOrEpoch` is the caller's range identifier (e.g. a\n * sequence number or epoch marker); the SAME range over the same channel must always\n * be passed in the same canonical form by the caller.\n */\nexport function chatMessageRangeResourceKey(\n channelId: string,\n messageSeqOrEpoch: string | number,\n): ResourceKey {\n return buildKey(\n 'chatMessageRange',\n `${channelId}#${messageSeqOrEpoch}`,\n channelId,\n String(messageSeqOrEpoch),\n );\n}\n\n/** Channel target = channelId. */\nexport function channelResourceKey(channelId: string): ResourceKey {\n return buildKey('channel', channelId, channelId);\n}\n\n/** Account target = uid. */\nexport function accountResourceKey(uid: string): ResourceKey {\n return buildKey('account', uid, uid);\n}\n\n/**\n * The composite doc id for a `safetyResourceCommands` row: `resourceKeyHash__commandId`.\n * ONE canonical derivation across the whole safety runtime — both the NCII removal and the\n * CSAM quarantine sagas write and read this collection, and a drift between two hand-written\n * joiners would target different docs, breaking command idempotency on a safety-critical path.\n * `PATH_BUILDERS.safetyResourceCommand(commandDocId)` consumes the result of this function.\n */\nexport function safetyResourceCommandDocId(resourceKeyHash: string, commandId: string): string {\n return `${resourceKeyHash}__${commandId}`;\n}\n"]}
|