|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The **one** answer to "which keys on an object's audit-provenance columns are |
| 5 | + * platform-owned rather than authorable?" — and the normalizer that applies |
| 6 | + * that answer to a metadata document (objectstack#4513, from objectstack#4447). |
| 7 | + * |
| 8 | + * ## What #4447 established, and the half it left open |
| 9 | + * |
| 10 | + * `applySystemFields` (`@objectstack/objectql`) injects the |
| 11 | + * {@link AUDIT_PROVENANCE_FIELDS} family and, since #4447, **forces** |
| 12 | + * `readonly: true` / `system: true` over a *declared* audit field as well: |
| 13 | + * `fields: { ...additions, ...schema.fields, ...overrides }`. That is what the |
| 14 | + * write path enforces — `ObjectQL.update` strips a non-system caller's write to |
| 15 | + * a statically-`readonly` field off the registry's post-injection schema, so a |
| 16 | + * forged `created_at` is refused whatever the author declared. |
| 17 | + * |
| 18 | + * The **read** path never learned it. `GET /api/v1/meta/objects/:name` answers |
| 19 | + * from `sys_metadata` (the stored overlay / build-artifact body) first and |
| 20 | + * consults the registry only as a fallback, so an object whose artifact ships a |
| 21 | + * materialized `created_at` carrying FieldSchema DEFAULTS (`readonly: false`) |
| 22 | + * reported `readonly: false` to every client while writes to that same field |
| 23 | + * were being refused. Measured on `origin/main` before this module existed, all |
| 24 | + * four protocol read exits agreed with each other and disagreed with the |
| 25 | + * engine: |
| 26 | + * |
| 27 | + * ``` |
| 28 | + * single read: {"type":"datetime","label":"Created At","readonly":false} |
| 29 | + * list read: {"type":"datetime","label":"Created At","readonly":false} |
| 30 | + * cached read: {"type":"datetime","label":"Created At","readonly":false} |
| 31 | + * layered read: {"type":"datetime","label":"Created At","readonly":false} |
| 32 | + * ``` |
| 33 | + * |
| 34 | + * One field, two answers, and the machine-readable one was the wrong one — the |
| 35 | + * face that clients, and AI authors writing code against `/meta`, are the only |
| 36 | + * ones able to see. |
| 37 | + * |
| 38 | + * ## Why this module lives in `@objectstack/metadata-core` |
| 39 | + * |
| 40 | + * The same criterion `engine-delete-dispatch.ts` records, for the same cycle: |
| 41 | + * `@objectstack/objectql` **depends on** `@objectstack/metadata-protocol`, so |
| 42 | + * the read path cannot import the governance table from the registry that owns |
| 43 | + * it. When a reverse import is impossible, the only honest way out is to sink |
| 44 | + * the contract into a package **both sides already depend on** — and this |
| 45 | + * package's own dependencies are `{ @objectstack/spec, zod }`, so there is no |
| 46 | + * new edge and no new cycle. |
| 47 | + * |
| 48 | + * The alternative — a second governance table inside the read path — is exactly |
| 49 | + * the drift this repo keeps paying for: the read would agree with the write |
| 50 | + * only until someone edited one side, which is the state #4513 records. |
| 51 | + * |
| 52 | + * ## What it deliberately does NOT do |
| 53 | + * |
| 54 | + * - **It governs only DECLARED audit fields.** `applySystemFields` *injects* an |
| 55 | + * absent one; this normalizer does not, because the served document is the |
| 56 | + * authored metadata document and injecting columns into it would rewrite what |
| 57 | + * a `GET` → `PUT` round-trip persists (the #4326 invariant) and what the |
| 58 | + * layered read reports as "customised". An absent field does not claim |
| 59 | + * `readonly: false`, so it is not the lie #4513 names. |
| 60 | + * - **It governs only the audit family.** A declared `organization_id` / |
| 61 | + * `owner_id` / `owning_business_unit_id` is the author's field and the |
| 62 | + * registry lets it win (those are `additions`, not `overrides`), so reporting |
| 63 | + * the author's value for them already agrees with what the write path |
| 64 | + * enforces. Forcing them here would create the mismatch in the other |
| 65 | + * direction. |
| 66 | + */ |
| 67 | + |
| 68 | +import { |
| 69 | + AUDIT_PROVENANCE_FIELDS, |
| 70 | + resolveInjectedSystemColumns, |
| 71 | + type AuditProvenanceField, |
| 72 | +} from '@objectstack/spec/data'; |
| 73 | + |
| 74 | +/** |
| 75 | + * The subset of an audit column's definition that is NOT authorable — the keys |
| 76 | + * that decide **who may write** the column. |
| 77 | + * |
| 78 | + * Only `readonly` / `system` travel: everything else an author writes — |
| 79 | + * `label`, `description`, `hidden`, `group`, and even `type` for an external |
| 80 | + * object mapping a differently-typed remote column — stays theirs. Narrower is |
| 81 | + * the point: this overrides an author, so it takes only what the defect |
| 82 | + * requires (#4447). |
| 83 | + * |
| 84 | + * Keyed by the spec's {@link AUDIT_PROVENANCE_FIELDS} tuple, so a name added |
| 85 | + * there without an entry here — or an entry for a name the spec dropped — is a |
| 86 | + * compile error rather than a silently diverging copy. |
| 87 | + */ |
| 88 | +export const AUDIT_FIELD_GOVERNANCE: Record<AuditProvenanceField, Record<string, unknown>> = |
| 89 | + Object.fromEntries( |
| 90 | + AUDIT_PROVENANCE_FIELDS.map((name) => [name, { readonly: true, system: true }]), |
| 91 | + ) as unknown as Record<AuditProvenanceField, Record<string, unknown>>; |
| 92 | + |
| 93 | +/** Does this field definition already carry every governance key at its governed value? */ |
| 94 | +function isGoverned(declared: unknown, governance: Record<string, unknown>): boolean { |
| 95 | + if (!declared || typeof declared !== 'object' || Array.isArray(declared)) return false; |
| 96 | + const rec = declared as Record<string, unknown>; |
| 97 | + for (const [key, value] of Object.entries(governance)) { |
| 98 | + if (rec[key] !== value) return false; |
| 99 | + } |
| 100 | + return true; |
| 101 | +} |
| 102 | + |
| 103 | +/** |
| 104 | + * Force {@link AUDIT_FIELD_GOVERNANCE} onto every audit-provenance field the |
| 105 | + * document declares, so what a reader is told about who may write the column |
| 106 | + * matches what the engine enforces. |
| 107 | + * |
| 108 | + * Pure and total, with the same tolerance contract as |
| 109 | + * {@link resolveInjectedSystemColumns}: any input may be handed to it, |
| 110 | + * including a bare record that has never been through Zod. Objects that opt out |
| 111 | + * of the audit family (`systemFields: false`, `systemFields.audit: false`, |
| 112 | + * `managedBy: 'better-auth'`) carry no platform governance and are returned |
| 113 | + * untouched — the same rows `applySystemFields` skips. |
| 114 | + * |
| 115 | + * Returns the **same reference** when nothing needed forcing, so a read path |
| 116 | + * that already agrees with the engine (a registry-sourced document, which went |
| 117 | + * through `applySystemFields` at registration) pays one comparison and no copy. |
| 118 | + * |
| 119 | + * @param doc An object metadata document, or any bare record shaped like one. |
| 120 | + */ |
| 121 | +export function applyAuditFieldGovernance<T>(doc: T): T { |
| 122 | + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return doc; |
| 123 | + const rec = doc as unknown as Record<string, unknown>; |
| 124 | + const fields = rec.fields; |
| 125 | + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return doc; |
| 126 | + |
| 127 | + // WHICH columns this object carries is the spec's derivation — the same one |
| 128 | + // `applySystemFields` consumes. Re-deriving the opt-out conditions here is |
| 129 | + // precisely the drift this module exists to prevent. |
| 130 | + if (!resolveInjectedSystemColumns(rec).audit) return doc; |
| 131 | + |
| 132 | + const declaredFields = fields as Record<string, unknown>; |
| 133 | + let governed: Record<string, unknown> | undefined; |
| 134 | + for (const name of AUDIT_PROVENANCE_FIELDS) { |
| 135 | + const declared = declaredFields[name]; |
| 136 | + // Absent is not a lie — see the module header. Only a DECLARED audit field |
| 137 | + // can claim a writability the engine refuses. |
| 138 | + if (declared === undefined || declared === null) continue; |
| 139 | + if (isGoverned(declared, AUDIT_FIELD_GOVERNANCE[name])) continue; |
| 140 | + governed ??= { ...declaredFields }; |
| 141 | + governed[name] = typeof declared === 'object' && !Array.isArray(declared) |
| 142 | + ? { ...(declared as Record<string, unknown>), ...AUDIT_FIELD_GOVERNANCE[name] } |
| 143 | + : { ...AUDIT_FIELD_GOVERNANCE[name] }; |
| 144 | + } |
| 145 | + |
| 146 | + if (governed === undefined) return doc; |
| 147 | + return { ...rec, fields: governed } as unknown as T; |
| 148 | +} |
0 commit comments