@ttt-productions/ttt-core 0.79.79 → 0.79.80

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.
@@ -51,25 +51,38 @@ export interface AppConfigLeverRepair {
51
51
  readonly lever: AppConfigLeverKey;
52
52
  readonly storedValue: unknown;
53
53
  }
54
- /** The doc a writer writes, and every stored lever the merge had to repair to write it. */
54
+ /**
55
+ * The doc a writer writes, every stored lever the merge had to repair to write it, and whether the
56
+ * stored version block must be left as it is.
57
+ */
55
58
  export interface AppConfigMerge {
56
59
  readonly next: AppConfig;
57
60
  readonly repairs: readonly AppConfigLeverRepair[];
61
+ /**
62
+ * True when the stored version block does not parse and the update does not replace it. `next`
63
+ * then holds no block, and the writer must write `next` as a MERGE (never a full overwrite), so
64
+ * the stored block is kept as it is — the read side keeps reporting it `invalid` (failing closed
65
+ * and alerting) instead of it disappearing and reading as "nothing released".
66
+ */
67
+ readonly keepsStoredVersionBlock: boolean;
58
68
  }
59
69
  /**
60
- * The complete `_appConfig/app` doc to write after applying `update` to `existing` (the doc as
61
- * read in the writer's transaction; `undefined` or `null` when it does not exist). The update's
62
- * fields win; `existing` supplies the rest; any field still missing comes from
63
- * DEFAULT_APP_CONFIG. An `undefined` update field changes nothing. Pure.
70
+ * The `_appConfig/app` doc to write after applying `update` to `existing` (the doc as read in the
71
+ * writer's transaction; `undefined` or `null` when it does not exist). The update's fields win;
72
+ * `existing` supplies the rest; any field still missing comes from DEFAULT_APP_CONFIG. An
73
+ * `undefined` update field changes nothing. Pure.
64
74
  *
65
- * A stored lever its schema refuses never blocks a save — a console typo must not disable the
66
- * maintenance kill switch: it is dropped, so the update's value replaces it when the update sends
67
- * that lever and its DEFAULT_APP_CONFIG value (what every reader already falls back to) otherwise,
68
- * and it is reported in `repairs`. The release-owned version block is not a lever and has no
69
- * default, so a refused stored block is still refused.
75
+ * A stored value its schema refuses never blocks a save — a console typo must not disable the
76
+ * maintenance kill switch:
77
+ * - a refused LEVER is dropped, so the update's value replaces it when the update sends that lever
78
+ * and its DEFAULT_APP_CONFIG value (what every reader already falls back to) otherwise; it is
79
+ * reported in `repairs`;
80
+ * - a refused version block has no default and must never read as "nothing released", so when the
81
+ * update does not send a block it is left untouched (`keepsStoredVersionBlock`); an update that
82
+ * sends a block replaces it.
70
83
  *
71
- * Throws when the result would not parse under AppConfigSchema — an invalid update, or a refused
72
- * version block — so nothing invalid is ever written.
84
+ * Throws when the update itself would not parse under AppConfigSchema, so nothing invalid is ever
85
+ * written.
73
86
  */
74
87
  export declare function mergeAppConfigUpdate(existing: Readonly<Record<string, unknown>> | null | undefined, update: Readonly<Partial<AppConfig>>): AppConfigMerge;
75
88
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"app-config.d.ts","sourceRoot":"","sources":["../../src/utils/app-config.ts"],"names":[],"mappings":"AAmBA,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAC3E,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,oCAAoC,CAAC;AAErF;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,SAAS,EAAE,wBAAwB,CAAC,CAAC,CAAC,CAAC;AAE5F,oEAAoE;AACpE,MAAM,MAAM,iBAAiB,GAAG,MAAM,eAAe,CAAC;AAEtD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,kBAAkB,EAAE,eAO/B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,2BAA2B,mHACqE,CAAC;AAE9G;;;GAGG;AACH,eAAO,MAAM,wBAAwB,EAAG,aAAsB,CAAC;AAE/D,qEAAqE;AACrE,eAAO,MAAM,0BAA0B,EAAG,aAAsB,CAAC;AAEjE,sEAAsE;AACtE,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,MAAM,EAAE,OAAO,0BAA0B,CAAC;CACpD;AASD,2EAA2E;AAC3E,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,yBAAyB,CAGlG;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAM/D;AAMD,yFAAyF;AACzF,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,KAAK,EAAE,iBAAiB,CAAC;IAClC,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;CAC/B;AAED,2FAA2F;AAC3F,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,SAAS,oBAAoB,EAAE,CAAC;CACnD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,EAC9D,MAAM,EAAE,QAAQ,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,GACnC,cAAc,CAqBhB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,iBAAiB,EAC5D,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,EAC9D,KAAK,EAAE,CAAC,GACP,eAAe,CAAC,CAAC,CAAC,CAKpB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,GAC7D,eAAe,CAMjB;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,iCAAiC,GACzC;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAA;CAAE,GACxD;IAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,0BAA0B,CAAA;CAAE,GACxE;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAO3D;;;;;GAKG;AACH,wBAAgB,8BAA8B,CAC5C,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,GAC7D,iCAAiC,CAQnC"}
1
+ {"version":3,"file":"app-config.d.ts","sourceRoot":"","sources":["../../src/utils/app-config.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAC3E,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,oCAAoC,CAAC;AAErF;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,SAAS,EAAE,wBAAwB,CAAC,CAAC,CAAC,CAAC;AAE5F,oEAAoE;AACpE,MAAM,MAAM,iBAAiB,GAAG,MAAM,eAAe,CAAC;AAEtD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,kBAAkB,EAAE,eAO/B,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,2BAA2B,mHACqE,CAAC;AAE9G;;;GAGG;AACH,eAAO,MAAM,wBAAwB,EAAG,aAAsB,CAAC;AAE/D,qEAAqE;AACrE,eAAO,MAAM,0BAA0B,EAAG,aAAsB,CAAC;AAEjE,sEAAsE;AACtE,MAAM,WAAW,yBAAyB;IACxC,QAAQ,CAAC,MAAM,EAAE,OAAO,0BAA0B,CAAC;CACpD;AASD,2EAA2E;AAC3E,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,yBAAyB,CAGlG;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAM/D;AAMD,yFAAyF;AACzF,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,KAAK,EAAE,iBAAiB,CAAC;IAClC,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;CAC/B;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,SAAS,oBAAoB,EAAE,CAAC;IAClD;;;;;OAKG;IACH,QAAQ,CAAC,uBAAuB,EAAE,OAAO,CAAC;CAC3C;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,EAC9D,MAAM,EAAE,QAAQ,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,GACnC,cAAc,CAuBhB;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,CAAC,SAAS,iBAAiB,EAC5D,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,EAC9D,KAAK,EAAE,CAAC,GACP,eAAe,CAAC,CAAC,CAAC,CAKpB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,GAC7D,eAAe,CAMjB;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,iCAAiC,GACzC;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAA;CAAE,GACxD;IAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,0BAA0B,CAAA;CAAE,GACxE;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAO3D;;;;;GAKG;AACH,wBAAgB,8BAA8B,CAC5C,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,IAAI,GAAG,SAAS,GAC7D,iCAAiC,CAQnC"}
@@ -6,7 +6,8 @@
6
6
  // wiped environment) creates a doc WITHOUT the fields AppConfigSchema requires. The write rule
7
7
  // is `mergeAppConfigUpdate(existing, update)`: the writer's update on top of the doc it read,
8
8
  // with any field that doc lacks — and any lever it holds at a value its schema refuses — taken
9
- // from DEFAULT_APP_CONFIG, validated as a whole.
9
+ // from DEFAULT_APP_CONFIG, validated as a whole. A stored version block that does not parse is
10
+ // never written over and never dropped: the writer leaves it as it is (see AppConfigMerge).
10
11
  //
11
12
  // READ. The stored doc is untrusted — an operator can console-edit any field into any shape —
12
13
  // and a config read must never take the product down by itself. The read rule is
@@ -78,19 +79,22 @@ function definedFields(fields) {
78
79
  return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
79
80
  }
80
81
  /**
81
- * The complete `_appConfig/app` doc to write after applying `update` to `existing` (the doc as
82
- * read in the writer's transaction; `undefined` or `null` when it does not exist). The update's
83
- * fields win; `existing` supplies the rest; any field still missing comes from
84
- * DEFAULT_APP_CONFIG. An `undefined` update field changes nothing. Pure.
82
+ * The `_appConfig/app` doc to write after applying `update` to `existing` (the doc as read in the
83
+ * writer's transaction; `undefined` or `null` when it does not exist). The update's fields win;
84
+ * `existing` supplies the rest; any field still missing comes from DEFAULT_APP_CONFIG. An
85
+ * `undefined` update field changes nothing. Pure.
85
86
  *
86
- * A stored lever its schema refuses never blocks a save — a console typo must not disable the
87
- * maintenance kill switch: it is dropped, so the update's value replaces it when the update sends
88
- * that lever and its DEFAULT_APP_CONFIG value (what every reader already falls back to) otherwise,
89
- * and it is reported in `repairs`. The release-owned version block is not a lever and has no
90
- * default, so a refused stored block is still refused.
87
+ * A stored value its schema refuses never blocks a save — a console typo must not disable the
88
+ * maintenance kill switch:
89
+ * - a refused LEVER is dropped, so the update's value replaces it when the update sends that lever
90
+ * and its DEFAULT_APP_CONFIG value (what every reader already falls back to) otherwise; it is
91
+ * reported in `repairs`;
92
+ * - a refused version block has no default and must never read as "nothing released", so when the
93
+ * update does not send a block it is left untouched (`keepsStoredVersionBlock`); an update that
94
+ * sends a block replaces it.
91
95
  *
92
- * Throws when the result would not parse under AppConfigSchema — an invalid update, or a refused
93
- * version block — so nothing invalid is ever written.
96
+ * Throws when the update itself would not parse under AppConfigSchema, so nothing invalid is ever
97
+ * written.
94
98
  */
95
99
  export function mergeAppConfigUpdate(existing, update) {
96
100
  const stored = definedFields(existing ?? {});
@@ -101,17 +105,19 @@ export function mergeAppConfigUpdate(existing, update) {
101
105
  repairs.push({ lever, storedValue: stored[lever] });
102
106
  delete stored[lever];
103
107
  }
104
- const parsed = AppConfigSchema.safeParse({
105
- ...DEFAULT_APP_CONFIG,
106
- ...stored,
107
- ...definedFields(update),
108
- });
108
+ const sent = definedFields(update);
109
+ const keepsStoredVersionBlock = 'publicDocumentVersions' in stored &&
110
+ !('publicDocumentVersions' in sent) &&
111
+ !AppConfigSchema.shape.publicDocumentVersions.safeParse(stored.publicDocumentVersions).success;
112
+ if (keepsStoredVersionBlock)
113
+ delete stored.publicDocumentVersions;
114
+ const parsed = AppConfigSchema.safeParse({ ...DEFAULT_APP_CONFIG, ...stored, ...sent });
109
115
  if (!parsed.success) {
110
116
  const issue = parsed.error.issues[0];
111
117
  const field = issue?.path.join('.') || 'document';
112
118
  throw new Error(`_appConfig/app would not match AppConfigSchema after this update (${field}: ${issue?.message ?? 'invalid'}). Nothing should be written.`);
113
119
  }
114
- return { next: parsed.data, repairs };
120
+ return { next: parsed.data, repairs, keepsStoredVersionBlock };
115
121
  }
116
122
  /**
117
123
  * One operator lever off an unvalidated `_appConfig/app` snapshot (the raw stored doc; `null` or
@@ -1 +1 @@
1
- {"version":3,"file":"app-config.js","sourceRoot":"","sources":["../../src/utils/app-config.ts"],"names":[],"mappings":"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","sourcesContent":["// The `_appConfig/app` singleton at rest, and the ONE rule each for writing to it and reading it.\n//\n// WRITE. Several backend writers touch this doc (the admin App configuration card through\n// updateAppConfig, the public-document release publish, a seed), and each sends only the fields\n// it owns. Written as a bare merge, the first of them to run after the doc is gone (a fresh or\n// wiped environment) creates a doc WITHOUT the fields AppConfigSchema requires. The write rule\n// is `mergeAppConfigUpdate(existing, update)`: the writer's update on top of the doc it read,\n// with any field that doc lacks — and any lever it holds at a value its schema refuses — taken\n// from DEFAULT_APP_CONFIG, validated as a whole.\n//\n// READ. The stored doc is untrusted — an operator can console-edit any field into any shape —\n// and a config read must never take the product down by itself. The read rule is\n// `readAppConfigLever(snapshot, lever)`: the lever's stored value when its AppConfigSchema field\n// accepts it, otherwise its DEFAULT_APP_CONFIG value, one lever at a time so a bad field never\n// costs the good ones. `readAppConfigLevers` is every lever through it. The backend's cached\n// snapshot reader and the client shell both read through these. The release-owned version block\n// has no default to fall back to, so its reader, `readPublicDocumentVersionBlock`, reports what it\n// found instead — absent, valid, or invalid — and each consumer applies its own policy.\n\nimport { AppConfigSchema, type AppConfig } from '../doc-schemas/system.js';\nimport type { PublicDocumentVersionBlock } from '../doc-schemas/public-documents.js';\n\n/**\n * Every operator lever of `_appConfig/app`, each at a value its AppConfigSchema field accepts.\n * The release-owned `publicDocumentVersions` block is not a lever.\n */\nexport type AppConfigLevers = Readonly<Required<Omit<AppConfig, 'publicDocumentVersions'>>>;\n\n/** The name of one operator lever — a key of DEFAULT_APP_CONFIG. */\nexport type AppConfigLeverKey = keyof AppConfigLevers;\n\n/**\n * Every operator lever of `_appConfig/app` at rest — the doc a fresh environment behaves as if\n * it held, and the value each reader falls back to for a missing or unreadable field. The OPEN\n * posture: maintenance off, registration open, no banner, no throttle.\n *\n * `appVersion` is `''`: no version has been published. VersionGate skips a falsy version, so it\n * stays dormant — no reload for any browser, whatever version it stored before — until an\n * operator sets a real one through updateAppConfig (whose input refuses `''`). From then on the\n * gate compares as usual.\n *\n * The release-owned `publicDocumentVersions` block is not a lever and has no default: it is\n * absent until the first release.\n */\nexport const DEFAULT_APP_CONFIG: AppConfigLevers = Object.freeze({\n appVersion: '',\n maintenanceMode: false,\n maintenanceMessage: '',\n registrationEnabled: true,\n announcementMessage: '',\n rateLimitMultiplier: 1,\n});\n\n/**\n * The line shown while maintenance is on and the operator left `maintenanceMessage` blank — on\n * the maintenance takeover screen and in the callable refusal alike.\n */\nexport const DEFAULT_MAINTENANCE_MESSAGE =\n 'TTT Productions is down for maintenance right now. We will be back shortly — thank you for your patience.';\n\n/**\n * The code the callable maintenance refusal is thrown with. `unavailable` is also what a real\n * outage produces, so the code alone never identifies the refusal — its details do.\n */\nexport const MAINTENANCE_REFUSAL_CODE = 'unavailable' as const;\n\n/** The `details.reason` the callable maintenance refusal carries. */\nexport const MAINTENANCE_REFUSAL_REASON = 'maintenance' as const;\n\n/** Structured details carried by the callable maintenance refusal. */\nexport interface MaintenanceRefusalDetails {\n readonly reason: typeof MAINTENANCE_REFUSAL_REASON;\n}\n\n// The refusal code as the server (`unavailable`) and the Firebase client SDK\n// (`functions/unavailable`) spell it.\nconst MAINTENANCE_REFUSAL_CODES: ReadonlySet<string> = new Set([\n MAINTENANCE_REFUSAL_CODE,\n `functions/${MAINTENANCE_REFUSAL_CODE}`,\n]);\n\n/** True when `details` is the maintenance refusal's structured details. */\nexport function isMaintenanceRefusalDetails(details: unknown): details is MaintenanceRefusalDetails {\n if (!details || typeof details !== 'object') return false;\n return (details as Record<string, unknown>).reason === MAINTENANCE_REFUSAL_REASON;\n}\n\n/**\n * True when `err` is the callable maintenance refusal — an `unavailable` error in either\n * spelling whose details carry `reason: 'maintenance'`. Works on the server-side error and on\n * the client-side callable error alike. Decided by the code and the details, never the message\n * text, so a genuine outage is never mistaken for a designed refusal.\n */\nexport function isMaintenanceRefusalError(err: unknown): boolean {\n if (!err || typeof err !== 'object') return false;\n const e = err as { code?: unknown; details?: unknown };\n return (\n typeof e.code === 'string' && MAINTENANCE_REFUSAL_CODES.has(e.code) && isMaintenanceRefusalDetails(e.details)\n );\n}\n\nfunction definedFields(fields: Readonly<Record<string, unknown>>): Record<string, unknown> {\n return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));\n}\n\n/** A stored lever its AppConfigSchema field refused, with the raw value the doc held. */\nexport interface AppConfigLeverRepair {\n readonly lever: AppConfigLeverKey;\n readonly storedValue: unknown;\n}\n\n/** The doc a writer writes, and every stored lever the merge had to repair to write it. */\nexport interface AppConfigMerge {\n readonly next: AppConfig;\n readonly repairs: readonly AppConfigLeverRepair[];\n}\n\n/**\n * The complete `_appConfig/app` doc to write after applying `update` to `existing` (the doc as\n * read in the writer's transaction; `undefined` or `null` when it does not exist). The update's\n * fields win; `existing` supplies the rest; any field still missing comes from\n * DEFAULT_APP_CONFIG. An `undefined` update field changes nothing. Pure.\n *\n * A stored lever its schema refuses never blocks a save — a console typo must not disable the\n * maintenance kill switch: it is dropped, so the update's value replaces it when the update sends\n * that lever and its DEFAULT_APP_CONFIG value (what every reader already falls back to) otherwise,\n * and it is reported in `repairs`. The release-owned version block is not a lever and has no\n * default, so a refused stored block is still refused.\n *\n * Throws when the result would not parse under AppConfigSchema — an invalid update, or a refused\n * version block — so nothing invalid is ever written.\n */\nexport function mergeAppConfigUpdate(\n existing: Readonly<Record<string, unknown>> | null | undefined,\n update: Readonly<Partial<AppConfig>>,\n): AppConfigMerge {\n const stored = definedFields(existing ?? {});\n const repairs: AppConfigLeverRepair[] = [];\n for (const lever of Object.keys(DEFAULT_APP_CONFIG) as AppConfigLeverKey[]) {\n if (!(lever in stored) || AppConfigSchema.shape[lever].safeParse(stored[lever]).success) continue;\n repairs.push({ lever, storedValue: stored[lever] });\n delete stored[lever];\n }\n const parsed = AppConfigSchema.safeParse({\n ...DEFAULT_APP_CONFIG,\n ...stored,\n ...definedFields(update),\n });\n if (!parsed.success) {\n const issue = parsed.error.issues[0];\n const field = issue?.path.join('.') || 'document';\n throw new Error(\n `_appConfig/app would not match AppConfigSchema after this update (${field}: ${issue?.message ?? 'invalid'}). Nothing should be written.`,\n );\n }\n return { next: parsed.data, repairs };\n}\n\n/**\n * One operator lever off an unvalidated `_appConfig/app` snapshot (the raw stored doc; `null` or\n * `undefined` when it is missing or not yet read): the stored value when its AppConfigSchema\n * field accepts it, otherwise the lever's DEFAULT_APP_CONFIG value — for an absent field and for\n * a value the schema refuses (a wrong type, an over-cap message, an out-of-range multiplier)\n * alike. Pure; never throws.\n */\nexport function readAppConfigLever<L extends AppConfigLeverKey>(\n snapshot: Readonly<Record<string, unknown>> | null | undefined,\n lever: L,\n): AppConfigLevers[L] {\n const parsed = AppConfigSchema.shape[lever].safeParse(snapshot?.[lever]);\n return parsed.success && parsed.data !== undefined\n ? (parsed.data as AppConfigLevers[L])\n : DEFAULT_APP_CONFIG[lever];\n}\n\n/**\n * Every operator lever off an unvalidated `_appConfig/app` snapshot, each read through\n * `readAppConfigLever` — so a field the schema refuses falls back to its default while every\n * valid field survives, and a missing doc reads as DEFAULT_APP_CONFIG: the open posture. Pure;\n * never throws.\n */\nexport function readAppConfigLevers(\n snapshot: Readonly<Record<string, unknown>> | null | undefined,\n): AppConfigLevers {\n const levers: Partial<Record<AppConfigLeverKey, unknown>> = {};\n for (const lever of Object.keys(DEFAULT_APP_CONFIG) as AppConfigLeverKey[]) {\n levers[lever] = readAppConfigLever(snapshot, lever);\n }\n return levers as AppConfigLevers;\n}\n\n/**\n * What an unvalidated `_appConfig/app` snapshot holds in its release-owned version block:\n * - `absent` — no block, so nothing has been released (every document at version 0, nothing\n * required); `block` is `undefined`, which every public-document rule reads that way;\n * - `valid` — the block, parsed;\n * - `invalid` — a block is stored but its AppConfigSchema field refuses it (`null` included);\n * `issue` names the first refused field. It carries no block, so no rule can be computed\n * from it by accident.\n */\nexport type PublicDocumentVersionBlockReading =\n | { readonly status: 'absent'; readonly block: undefined }\n | { readonly status: 'valid'; readonly block: PublicDocumentVersionBlock }\n | { readonly status: 'invalid'; readonly issue: string };\n\nconst ABSENT_PUBLIC_DOCUMENT_VERSION_BLOCK: PublicDocumentVersionBlockReading = Object.freeze({\n status: 'absent',\n block: undefined,\n});\n\n/**\n * The version block off an unvalidated `_appConfig/app` snapshot (the raw stored doc; `null` or\n * `undefined` when it is missing or not yet read), judged by its AppConfigSchema field. Unlike a\n * lever there is no default to fall back to, so an invalid block is reported, never read as\n * absent: what to do about it is the caller's policy. Pure; never throws.\n */\nexport function readPublicDocumentVersionBlock(\n snapshot: Readonly<Record<string, unknown>> | null | undefined,\n): PublicDocumentVersionBlockReading {\n const stored = snapshot?.publicDocumentVersions;\n if (stored === undefined) return ABSENT_PUBLIC_DOCUMENT_VERSION_BLOCK;\n const parsed = AppConfigSchema.shape.publicDocumentVersions.safeParse(stored);\n if (parsed.success && parsed.data !== undefined) return { status: 'valid', block: parsed.data };\n const issue = parsed.success ? undefined : parsed.error.issues[0];\n const field = ['publicDocumentVersions', ...(issue?.path ?? []).map(String)].join('.');\n return { status: 'invalid', issue: `${field}: ${issue?.message ?? 'invalid'}` };\n}\n"]}
1
+ {"version":3,"file":"app-config.js","sourceRoot":"","sources":["../../src/utils/app-config.ts"],"names":[],"mappings":"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","sourcesContent":["// The `_appConfig/app` singleton at rest, and the ONE rule each for writing to it and reading it.\n//\n// WRITE. Several backend writers touch this doc (the admin App configuration card through\n// updateAppConfig, the public-document release publish, a seed), and each sends only the fields\n// it owns. Written as a bare merge, the first of them to run after the doc is gone (a fresh or\n// wiped environment) creates a doc WITHOUT the fields AppConfigSchema requires. The write rule\n// is `mergeAppConfigUpdate(existing, update)`: the writer's update on top of the doc it read,\n// with any field that doc lacks — and any lever it holds at a value its schema refuses — taken\n// from DEFAULT_APP_CONFIG, validated as a whole. A stored version block that does not parse is\n// never written over and never dropped: the writer leaves it as it is (see AppConfigMerge).\n//\n// READ. The stored doc is untrusted — an operator can console-edit any field into any shape —\n// and a config read must never take the product down by itself. The read rule is\n// `readAppConfigLever(snapshot, lever)`: the lever's stored value when its AppConfigSchema field\n// accepts it, otherwise its DEFAULT_APP_CONFIG value, one lever at a time so a bad field never\n// costs the good ones. `readAppConfigLevers` is every lever through it. The backend's cached\n// snapshot reader and the client shell both read through these. The release-owned version block\n// has no default to fall back to, so its reader, `readPublicDocumentVersionBlock`, reports what it\n// found instead — absent, valid, or invalid — and each consumer applies its own policy.\n\nimport { AppConfigSchema, type AppConfig } from '../doc-schemas/system.js';\nimport type { PublicDocumentVersionBlock } from '../doc-schemas/public-documents.js';\n\n/**\n * Every operator lever of `_appConfig/app`, each at a value its AppConfigSchema field accepts.\n * The release-owned `publicDocumentVersions` block is not a lever.\n */\nexport type AppConfigLevers = Readonly<Required<Omit<AppConfig, 'publicDocumentVersions'>>>;\n\n/** The name of one operator lever — a key of DEFAULT_APP_CONFIG. */\nexport type AppConfigLeverKey = keyof AppConfigLevers;\n\n/**\n * Every operator lever of `_appConfig/app` at rest — the doc a fresh environment behaves as if\n * it held, and the value each reader falls back to for a missing or unreadable field. The OPEN\n * posture: maintenance off, registration open, no banner, no throttle.\n *\n * `appVersion` is `''`: no version has been published. VersionGate skips a falsy version, so it\n * stays dormant — no reload for any browser, whatever version it stored before — until an\n * operator sets a real one through updateAppConfig (whose input refuses `''`). From then on the\n * gate compares as usual.\n *\n * The release-owned `publicDocumentVersions` block is not a lever and has no default: it is\n * absent until the first release.\n */\nexport const DEFAULT_APP_CONFIG: AppConfigLevers = Object.freeze({\n appVersion: '',\n maintenanceMode: false,\n maintenanceMessage: '',\n registrationEnabled: true,\n announcementMessage: '',\n rateLimitMultiplier: 1,\n});\n\n/**\n * The line shown while maintenance is on and the operator left `maintenanceMessage` blank — on\n * the maintenance takeover screen and in the callable refusal alike.\n */\nexport const DEFAULT_MAINTENANCE_MESSAGE =\n 'TTT Productions is down for maintenance right now. We will be back shortly — thank you for your patience.';\n\n/**\n * The code the callable maintenance refusal is thrown with. `unavailable` is also what a real\n * outage produces, so the code alone never identifies the refusal — its details do.\n */\nexport const MAINTENANCE_REFUSAL_CODE = 'unavailable' as const;\n\n/** The `details.reason` the callable maintenance refusal carries. */\nexport const MAINTENANCE_REFUSAL_REASON = 'maintenance' as const;\n\n/** Structured details carried by the callable maintenance refusal. */\nexport interface MaintenanceRefusalDetails {\n readonly reason: typeof MAINTENANCE_REFUSAL_REASON;\n}\n\n// The refusal code as the server (`unavailable`) and the Firebase client SDK\n// (`functions/unavailable`) spell it.\nconst MAINTENANCE_REFUSAL_CODES: ReadonlySet<string> = new Set([\n MAINTENANCE_REFUSAL_CODE,\n `functions/${MAINTENANCE_REFUSAL_CODE}`,\n]);\n\n/** True when `details` is the maintenance refusal's structured details. */\nexport function isMaintenanceRefusalDetails(details: unknown): details is MaintenanceRefusalDetails {\n if (!details || typeof details !== 'object') return false;\n return (details as Record<string, unknown>).reason === MAINTENANCE_REFUSAL_REASON;\n}\n\n/**\n * True when `err` is the callable maintenance refusal — an `unavailable` error in either\n * spelling whose details carry `reason: 'maintenance'`. Works on the server-side error and on\n * the client-side callable error alike. Decided by the code and the details, never the message\n * text, so a genuine outage is never mistaken for a designed refusal.\n */\nexport function isMaintenanceRefusalError(err: unknown): boolean {\n if (!err || typeof err !== 'object') return false;\n const e = err as { code?: unknown; details?: unknown };\n return (\n typeof e.code === 'string' && MAINTENANCE_REFUSAL_CODES.has(e.code) && isMaintenanceRefusalDetails(e.details)\n );\n}\n\nfunction definedFields(fields: Readonly<Record<string, unknown>>): Record<string, unknown> {\n return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));\n}\n\n/** A stored lever its AppConfigSchema field refused, with the raw value the doc held. */\nexport interface AppConfigLeverRepair {\n readonly lever: AppConfigLeverKey;\n readonly storedValue: unknown;\n}\n\n/**\n * The doc a writer writes, every stored lever the merge had to repair to write it, and whether the\n * stored version block must be left as it is.\n */\nexport interface AppConfigMerge {\n readonly next: AppConfig;\n readonly repairs: readonly AppConfigLeverRepair[];\n /**\n * True when the stored version block does not parse and the update does not replace it. `next`\n * then holds no block, and the writer must write `next` as a MERGE (never a full overwrite), so\n * the stored block is kept as it is — the read side keeps reporting it `invalid` (failing closed\n * and alerting) instead of it disappearing and reading as \"nothing released\".\n */\n readonly keepsStoredVersionBlock: boolean;\n}\n\n/**\n * The `_appConfig/app` doc to write after applying `update` to `existing` (the doc as read in the\n * writer's transaction; `undefined` or `null` when it does not exist). The update's fields win;\n * `existing` supplies the rest; any field still missing comes from DEFAULT_APP_CONFIG. An\n * `undefined` update field changes nothing. Pure.\n *\n * A stored value its schema refuses never blocks a save — a console typo must not disable the\n * maintenance kill switch:\n * - a refused LEVER is dropped, so the update's value replaces it when the update sends that lever\n * and its DEFAULT_APP_CONFIG value (what every reader already falls back to) otherwise; it is\n * reported in `repairs`;\n * - a refused version block has no default and must never read as \"nothing released\", so when the\n * update does not send a block it is left untouched (`keepsStoredVersionBlock`); an update that\n * sends a block replaces it.\n *\n * Throws when the update itself would not parse under AppConfigSchema, so nothing invalid is ever\n * written.\n */\nexport function mergeAppConfigUpdate(\n existing: Readonly<Record<string, unknown>> | null | undefined,\n update: Readonly<Partial<AppConfig>>,\n): AppConfigMerge {\n const stored = definedFields(existing ?? {});\n const repairs: AppConfigLeverRepair[] = [];\n for (const lever of Object.keys(DEFAULT_APP_CONFIG) as AppConfigLeverKey[]) {\n if (!(lever in stored) || AppConfigSchema.shape[lever].safeParse(stored[lever]).success) continue;\n repairs.push({ lever, storedValue: stored[lever] });\n delete stored[lever];\n }\n const sent = definedFields(update);\n const keepsStoredVersionBlock =\n 'publicDocumentVersions' in stored &&\n !('publicDocumentVersions' in sent) &&\n !AppConfigSchema.shape.publicDocumentVersions.safeParse(stored.publicDocumentVersions).success;\n if (keepsStoredVersionBlock) delete stored.publicDocumentVersions;\n const parsed = AppConfigSchema.safeParse({ ...DEFAULT_APP_CONFIG, ...stored, ...sent });\n if (!parsed.success) {\n const issue = parsed.error.issues[0];\n const field = issue?.path.join('.') || 'document';\n throw new Error(\n `_appConfig/app would not match AppConfigSchema after this update (${field}: ${issue?.message ?? 'invalid'}). Nothing should be written.`,\n );\n }\n return { next: parsed.data, repairs, keepsStoredVersionBlock };\n}\n\n/**\n * One operator lever off an unvalidated `_appConfig/app` snapshot (the raw stored doc; `null` or\n * `undefined` when it is missing or not yet read): the stored value when its AppConfigSchema\n * field accepts it, otherwise the lever's DEFAULT_APP_CONFIG value — for an absent field and for\n * a value the schema refuses (a wrong type, an over-cap message, an out-of-range multiplier)\n * alike. Pure; never throws.\n */\nexport function readAppConfigLever<L extends AppConfigLeverKey>(\n snapshot: Readonly<Record<string, unknown>> | null | undefined,\n lever: L,\n): AppConfigLevers[L] {\n const parsed = AppConfigSchema.shape[lever].safeParse(snapshot?.[lever]);\n return parsed.success && parsed.data !== undefined\n ? (parsed.data as AppConfigLevers[L])\n : DEFAULT_APP_CONFIG[lever];\n}\n\n/**\n * Every operator lever off an unvalidated `_appConfig/app` snapshot, each read through\n * `readAppConfigLever` — so a field the schema refuses falls back to its default while every\n * valid field survives, and a missing doc reads as DEFAULT_APP_CONFIG: the open posture. Pure;\n * never throws.\n */\nexport function readAppConfigLevers(\n snapshot: Readonly<Record<string, unknown>> | null | undefined,\n): AppConfigLevers {\n const levers: Partial<Record<AppConfigLeverKey, unknown>> = {};\n for (const lever of Object.keys(DEFAULT_APP_CONFIG) as AppConfigLeverKey[]) {\n levers[lever] = readAppConfigLever(snapshot, lever);\n }\n return levers as AppConfigLevers;\n}\n\n/**\n * What an unvalidated `_appConfig/app` snapshot holds in its release-owned version block:\n * - `absent` — no block, so nothing has been released (every document at version 0, nothing\n * required); `block` is `undefined`, which every public-document rule reads that way;\n * - `valid` — the block, parsed;\n * - `invalid` — a block is stored but its AppConfigSchema field refuses it (`null` included);\n * `issue` names the first refused field. It carries no block, so no rule can be computed\n * from it by accident.\n */\nexport type PublicDocumentVersionBlockReading =\n | { readonly status: 'absent'; readonly block: undefined }\n | { readonly status: 'valid'; readonly block: PublicDocumentVersionBlock }\n | { readonly status: 'invalid'; readonly issue: string };\n\nconst ABSENT_PUBLIC_DOCUMENT_VERSION_BLOCK: PublicDocumentVersionBlockReading = Object.freeze({\n status: 'absent',\n block: undefined,\n});\n\n/**\n * The version block off an unvalidated `_appConfig/app` snapshot (the raw stored doc; `null` or\n * `undefined` when it is missing or not yet read), judged by its AppConfigSchema field. Unlike a\n * lever there is no default to fall back to, so an invalid block is reported, never read as\n * absent: what to do about it is the caller's policy. Pure; never throws.\n */\nexport function readPublicDocumentVersionBlock(\n snapshot: Readonly<Record<string, unknown>> | null | undefined,\n): PublicDocumentVersionBlockReading {\n const stored = snapshot?.publicDocumentVersions;\n if (stored === undefined) return ABSENT_PUBLIC_DOCUMENT_VERSION_BLOCK;\n const parsed = AppConfigSchema.shape.publicDocumentVersions.safeParse(stored);\n if (parsed.success && parsed.data !== undefined) return { status: 'valid', block: parsed.data };\n const issue = parsed.success ? undefined : parsed.error.issues[0];\n const field = ['publicDocumentVersions', ...(issue?.path ?? []).map(String)].join('.');\n return { status: 'invalid', issue: `${field}: ${issue?.message ?? 'invalid'}` };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttt-productions/ttt-core",
3
- "version": "0.79.79",
3
+ "version": "0.79.80",
4
4
  "description": "Core types, Firestore path constants, and shared constants for TTT Productions",
5
5
  "repository": {
6
6
  "type": "git",