|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Compile-level pins for `ScriptContext.user` / {@link ScriptUser} (#5521). |
| 5 | + * |
| 6 | + * ## Why a plain src module and not a test |
| 7 | + * |
| 8 | + * `packages/runtime/tsconfig.json` EXCLUDES every `.test.ts` and `.spec.ts` |
| 9 | + * file, and the package's `typecheck` script is a bare `tsc --noEmit` over that |
| 10 | + * config (the exclusion glob is not spelled here because it cannot be: its |
| 11 | + * leading wildcard pair would close this comment) — so a |
| 12 | + * `@ts-expect-error` written in a runtime test file is compiled by NOTHING and |
| 13 | + * evaluates never. Deleting such a directive leaves every gate just as green, |
| 14 | + * which is the definition of a phantom check; `check:type-check-coverage`'s |
| 15 | + * PINS_CHECKED invariant fails on one, and its PHANTOM_PIN_DEBT ledger is |
| 16 | + * closed to new entries. The same reasoning put |
| 17 | + * `packages/spec/src/ui/app.nav-type-assertions.ts` in src, and this file |
| 18 | + * follows it. |
| 19 | + * |
| 20 | + * The file is referenced by no tsup entry (`entry: ['src/index.ts']`) and |
| 21 | + * re-exported by no barrel, so it adds nothing to any build. Everything is |
| 22 | + * `export`ed because the repo compiles with `noUnusedLocals`. |
| 23 | + * |
| 24 | + * ## What is pinned, and in which direction |
| 25 | + * |
| 26 | + * The runtime VALUE has been right and pin-tested since #5372 — |
| 27 | + * `../action-ctx-user-shape.test.ts` asserts the three dispatch paths key for |
| 28 | + * key and value for value. That pin cannot see the case this file exists for: |
| 29 | + * a FOURTH dispatch face hand-rolling a fifth user shape, which is what |
| 30 | + * `user?: unknown` used to permit in silence and is the mechanism by which |
| 31 | + * #5372's three disagreeing shapes survived several versions — there was no |
| 32 | + * declaration to violate. |
| 33 | + * |
| 34 | + * So the assertions come in two families, and BOTH matter: |
| 35 | + * |
| 36 | + * - POSITIVE — each of the two REAL producer shapes still assigns. These are |
| 37 | + * the over-narrowing guard: collapse the union to `ActorUser` and the hook |
| 38 | + * arm's assertions go red, which is the whole reason this is a union. |
| 39 | + * - NEGATIVE (`@ts-expect-error`) — shapes that must NOT assign. If the type |
| 40 | + * ever widens back toward `unknown`, the now-unused suppressions become the |
| 41 | + * compile error. This is the half that catches "accepts too much", and it is |
| 42 | + * the half the seam was missing entirely. |
| 43 | + */ |
| 44 | + |
| 45 | +import type { HookContext } from '@objectstack/spec/data'; |
| 46 | +import type { EvalUser } from '@objectstack/spec/identity'; |
| 47 | + |
| 48 | +import type { ActorUser } from '../security/actor-user.js'; |
| 49 | +import type { ScriptContext, ScriptUser } from './script-runner.js'; |
| 50 | + |
| 51 | +/* ──────────────────────────────────────────────────────────────────────────── |
| 52 | + * POSITIVE — the two real producers, and the third real VALUE. |
| 53 | + * ──────────────────────────────────────────────────────────────────────────── */ |
| 54 | + |
| 55 | +/** |
| 56 | + * The ACTION arm, exactly as `buildActorUser()` emits it (`../security/actor-user.ts`): |
| 57 | + * the `EvalUser` identity core, the two transport aliases, and the two separate |
| 58 | + * authority channels. Post-#6011 there is no `roles` alias — `positions` is the |
| 59 | + * one spelling, and adding `roles` back here would fail the excess-property |
| 60 | + * check, which is a bonus pin on that retirement. |
| 61 | + */ |
| 62 | +export const actionProducerShape: ScriptUser = { |
| 63 | + id: 'usr_admin', |
| 64 | + userId: 'usr_admin', |
| 65 | + name: 'Ada Lovelace', |
| 66 | + displayName: 'Ada Lovelace', |
| 67 | + email: 'ada@objectos.ai', |
| 68 | + positions: ['platform_admin'], |
| 69 | + isPlatformAdmin: true, |
| 70 | + organizationId: 'org_1', |
| 71 | + permissions: ['admin_full_access'], |
| 72 | + systemPermissions: ['manage_metadata'], |
| 73 | +}; |
| 74 | + |
| 75 | +/** The same shape arriving under its own name, not as a literal. */ |
| 76 | +export const actionProducerNamed = (u: ActorUser): ScriptUser => u; |
| 77 | + |
| 78 | +/** |
| 79 | + * The HOOK arm, exactly as ObjectQL's `buildUser()` emits it |
| 80 | + * (`packages/objectql/src/engine.ts`) for a fully-populated execution context. |
| 81 | + * Note what is absent and must STAY absent-legal: `positions`, `permissions`, |
| 82 | + * `systemPermissions`, `userId`, `displayName`. |
| 83 | + */ |
| 84 | +export const hookProducerShape: ScriptUser = { |
| 85 | + id: 'usr_admin', |
| 86 | + email: 'ada@objectos.ai', |
| 87 | + organizationId: 'org_1', |
| 88 | +}; |
| 89 | + |
| 90 | +/** |
| 91 | + * `buildUser()`'s minimum: an execution context with a `userId` and nothing |
| 92 | + * else. This is the assertion that goes red first if anyone collapses the union |
| 93 | + * to `ActorUser`. |
| 94 | + */ |
| 95 | +export const hookProducerMinimal: ScriptUser = { id: 'usr_admin' }; |
| 96 | + |
| 97 | +/** The same shape arriving under its declared spec name. */ |
| 98 | +export const hookProducerNamed = (u: HookContext['user']): ScriptUser => u; |
| 99 | + |
| 100 | +/** |
| 101 | + * `undefined` is a REAL value on this seam, not merely the optionality of the |
| 102 | + * key: ObjectQL's `ScopedRepo.execute()` — the second `executeAction` call site |
| 103 | + * — passes an action context carrying neither `user` nor `session`, so both |
| 104 | + * arms of `body-runner.ts:340` resolve to `undefined`. |
| 105 | + */ |
| 106 | +export const absentUser: ScriptUser = undefined; |
| 107 | + |
| 108 | +/** Both faces assembled at the real seam, so the field's own type is pinned too. */ |
| 109 | +export const actionSeamContext: ScriptContext = { |
| 110 | + input: { amount: 100 }, |
| 111 | + user: actionProducerShape, |
| 112 | + session: { userId: 'usr_admin', organizationId: 'org_1', positions: ['platform_admin'] }, |
| 113 | +}; |
| 114 | + |
| 115 | +export const hookSeamContext: ScriptContext = { |
| 116 | + input: { id: 'rec_1' }, |
| 117 | + user: hookProducerShape, |
| 118 | + session: { userId: 'usr_admin', organizationId: 'org_1' }, |
| 119 | + event: 'beforeInsert', |
| 120 | + object: 'crm_case', |
| 121 | +}; |
| 122 | + |
| 123 | +/** A body-less / system dispatch: the seam carries no caller at all. */ |
| 124 | +export const anonymousSeamContext: ScriptContext = { input: {} }; |
| 125 | + |
| 126 | +/** |
| 127 | + * The practical payoff, and the same one the sibling `ScriptSession` states: |
| 128 | + * `id` is declared on BOTH arms, so a consumer reading only the shared key needs no |
| 129 | + * discrimination. It is `string | undefined` because the hook arm's `id` is |
| 130 | + * optional — narrower than `unknown` by exactly the useful amount. |
| 131 | + */ |
| 132 | +export const sharedIdIsReadable = (ctx: ScriptContext): string | undefined => ctx.user?.id; |
| 133 | + |
| 134 | +/* ──────────────────────────────────────────────────────────────────────────── |
| 135 | + * NEGATIVE — must NOT assign. An unused suppression here IS the failure. |
| 136 | + * ──────────────────────────────────────────────────────────────────────────── */ |
| 137 | + |
| 138 | +/** A fourth dispatch face inventing its own vocabulary — the #5372 mechanism. */ |
| 139 | +// @ts-expect-error - an arbitrary shape is not a producer shape (#5521) |
| 140 | +export const inventedShape: ScriptUser = { currentUser: 'usr_admin', tenant: 'org_1' }; |
| 141 | + |
| 142 | +/** A declared key carrying the wrong type. */ |
| 143 | +// @ts-expect-error - `id` is a string on both arms (#5521) |
| 144 | +export const wrongIdType: ScriptUser = { id: 42 }; |
| 145 | + |
| 146 | +/** The caller is an object on every path, never a bare identifier. */ |
| 147 | +// @ts-expect-error - a user id string is not a user (#5521) |
| 148 | +export const primitiveUser: ScriptUser = 'usr_admin'; |
| 149 | + |
| 150 | +/** |
| 151 | + * Action-SHAPED but incomplete — the failure mode #5372 actually shipped, where |
| 152 | + * a dispatcher hand-rolled a partial envelope. Rejected because it satisfies |
| 153 | + * neither arm: `ActorUser` requires the aliases and both authority channels, |
| 154 | + * and this shares no key with the hook shortcut, so the weak-type check refuses |
| 155 | + * it there ("no properties in common"). |
| 156 | + */ |
| 157 | +// @ts-expect-error - a partial ActorUser is not an ActorUser (#5521) |
| 158 | +export const partialActionShape: ScriptUser = { userId: 'usr_admin', positions: ['platform_admin'] }; |
| 159 | + |
| 160 | +/** |
| 161 | + * ⚠️ The limit of this union, pinned as a POSITIVE because it is what actually |
| 162 | + * compiles — stated here rather than left for the next reader to discover. |
| 163 | + * |
| 164 | + * A partial action envelope that happens to carry a hook-arm key assigns, via |
| 165 | + * the hook arm. Two ordinary TypeScript rules combine to allow it: the hook arm |
| 166 | + * is a WEAK type (every key optional), so one matching key is enough to satisfy |
| 167 | + * it; and excess-property checking on a union rejects only keys present in NO |
| 168 | + * member, so `positions` — real on the `ActorUser` arm — is not excess here. |
| 169 | + * |
| 170 | + * A union of two undiscriminated producer shapes cannot do better, and neither |
| 171 | + * can the sibling `ScriptSession`, whose `ActionSession` arm is all-optional |
| 172 | + * for the same reason. What the declaration buys is not a proof of |
| 173 | + * well-formedness; it is that a shape sharing NOTHING with either producer |
| 174 | + * ({@link inventedShape}) is now refused where `unknown` accepted it silently. |
| 175 | + * Closing the remaining gap would need a discriminant on the seam — the body |
| 176 | + * kind, which this interface deliberately does not carry (see `ScriptSession`). |
| 177 | + */ |
| 178 | +export const partialShapeBorrowingHookArm: ScriptUser = { |
| 179 | + id: 'usr_admin', |
| 180 | + positions: ['platform_admin'], |
| 181 | +}; |
| 182 | + |
| 183 | +/** |
| 184 | + * The spec's `EvalUser` (ADR-0068 D1) — the issue's option 1, refused on |
| 185 | + * MEASUREMENT rather than taste. It is a SUPERSET of what the hook side |
| 186 | + * delivers (`buildUser()` emits no `positions`) and a SUBSET of what the action |
| 187 | + * side delivers, so it describes neither producer. `ActorUser extends EvalUser` |
| 188 | + * keeps the ADR-0068 contract on the path that actually has it. |
| 189 | + */ |
| 190 | +// @ts-expect-error - EvalUser is not a producer shape on this seam (#5521) |
| 191 | +export const bareEvalUser = (u: EvalUser): ScriptUser => u; |
| 192 | + |
| 193 | +/** |
| 194 | + * The union did not collapse to `ActorUser`: `positions` is unreadable without |
| 195 | + * discriminating the body kind, because the hook arm has no such key. This is |
| 196 | + * the over-narrowing guard stated from the READ side — if someone later |
| 197 | + * declares `user?: ActorUser`, this suppression goes unused and fails. |
| 198 | + */ |
| 199 | +export const positionsNeedDiscrimination = (ctx: ScriptContext): unknown => |
| 200 | + // @ts-expect-error - `positions` exists on the action arm only (#5521) |
| 201 | + ctx.user?.positions; |
0 commit comments