@12-apps/prisma 1.0.0
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.
- package/README.md +95 -0
- package/dist/actor-context.d.ts +127 -0
- package/dist/actor-context.js +139 -0
- package/dist/append-only-extension.d.ts +28 -0
- package/dist/append-only-extension.js +72 -0
- package/dist/audit-extension.d.ts +18 -0
- package/dist/audit-extension.js +123 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +213 -0
- package/dist/search-normalize.d.ts +13 -0
- package/dist/search-normalize.js +20 -0
- package/package.json +96 -0
- package/prisma/migration-files.ts +33 -0
- package/prisma/migrations/20260725150000_payments_platform_core/migration.sql +98 -0
- package/prisma/migrations/20260725160000_payments_oauth_connections/migration.sql +25 -0
- package/prisma/migrations/20260725170000_add_saved_reports/migration.sql +22 -0
- package/prisma/migrations/20260726090000_payments_multi_provider_failover/migration.sql +67 -0
- package/prisma/migrations/20260726120000_payments_failover_policy/migration.sql +29 -0
- package/prisma/migrations/20260726130000_add_report_lifecycle/migration.sql +18 -0
- package/prisma/migrations/20260727120000_add_report_archived_status/migration.sql +9 -0
- package/prisma/migrations/20260727120000_payments_webhook_replay_budget/migration.sql +37 -0
- package/prisma/migrations/20260727190000_sweep_leases/migration.sql +13 -0
- package/prisma/migrations/20260728120000_add_product_research/migration.sql +112 -0
- package/prisma/migrations/20260728170000_add_manual_price_entries/migration.sql +31 -0
- package/prisma/migrations/20260729090000_integration_source_singleton/migration.sql +24 -0
- package/prisma/migrations/20260729120000_price_source_soft_delete/migration.sql +24 -0
- package/prisma/migrations/20260729140000_research_term_normalized/migration.sql +26 -0
- package/prisma/migrations/20260730120000_offer_outside_delivery_area/migration.sql +16 -0
- package/prisma/migrations/20260730120000_payments_charge_verified_at/migration.sql +23 -0
- package/prisma/migrations/20260730130000_research_runs_created_at_index/migration.sql +12 -0
- package/prisma/migrations/20260730210000_add_shifts/migration.sql +95 -0
- package/prisma/migrations/20260731000000_offer_shipping_unknown/migration.sql +34 -0
- package/prisma/migrations/20260731210000_shift_delete_guard/migration.sql +43 -0
- package/prisma/migrations/20260810120000_add_report_default_range/migration.sql +15 -0
- package/prisma/migrations/20260810160000_report_default_range_month/migration.sql +15 -0
- package/prisma/migrations/20260810180000_add_report_working_copy/migration.sql +13 -0
- package/prisma/plugin-migrations.json +28 -0
- package/prisma/schema/entity-lifecycle.prisma +126 -0
- package/prisma/schema/jobs.prisma +48 -0
- package/prisma/schema/product-research.prisma +217 -0
- package/prisma/schema/schema.prisma +19 -0
- package/prisma/schema/shift.prisma +34 -0
- package/src/actor-context.ts +218 -0
- package/src/append-only-extension.ts +75 -0
- package/src/audit-extension.ts +121 -0
- package/src/index.ts +233 -0
- package/src/search-normalize.ts +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# @12-apps/prisma
|
|
2
|
+
|
|
3
|
+
The repo's **Prisma host**. Everything Prisma-shaped lives here and nowhere
|
|
4
|
+
else: the multi-file schema folder, the migrations folder, the sync scripts that
|
|
5
|
+
pull plugin-owned models and migrations in, and the runtime `PrismaClient`
|
|
6
|
+
singleton with its audit / append-only extensions.
|
|
7
|
+
|
|
8
|
+
It was carved out of `@12-apps/shared-helpers`, which is a bag of generic
|
|
9
|
+
utilities (S3, caching, requests, money, dates) and had no business owning a
|
|
10
|
+
database schema. Splitting it means a consumer that wants `formatMoney` no
|
|
11
|
+
longer installs `@prisma/client`, PGlite and a WASM Postgres to get it.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
packages/prisma/
|
|
15
|
+
prisma/
|
|
16
|
+
schema/ # datasource + generator, plus the plugin partials
|
|
17
|
+
migrations/ # every committed migration, host- and plugin-owned
|
|
18
|
+
plugin-migrations.json # generated: which migrations came from a plugin
|
|
19
|
+
migration-files.ts # side-effect-free migration discovery
|
|
20
|
+
prisma.config.ts # Prisma 7 config (schema/migrations paths, datasource)
|
|
21
|
+
scripts/ # the sync + verification scripts
|
|
22
|
+
src/ # the runtime client and its extensions
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## What it exports
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import {
|
|
29
|
+
getPrismaClient,
|
|
30
|
+
setPrismaClient,
|
|
31
|
+
resetPrismaClient,
|
|
32
|
+
runWithActor,
|
|
33
|
+
setActor,
|
|
34
|
+
getActorUserId,
|
|
35
|
+
getActorAttribution,
|
|
36
|
+
normalizeSearchText,
|
|
37
|
+
AppendOnlyViolationError,
|
|
38
|
+
type PrismaClient,
|
|
39
|
+
} from '@12-apps/prisma';
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`getPrismaClient()` is lazy and memoised on `globalThis`, so Next dev /
|
|
43
|
+
Turbopack hot-reload never spawns a second PGlite instance. It builds a
|
|
44
|
+
PostgreSQL-backed client by default and a PGlite-backed one when `USE_FILE_DB`,
|
|
45
|
+
`PGLITE_DATA_DIR` or a `pglite:` `DATABASE_URL` selects it — never in
|
|
46
|
+
production unless `USE_FILE_DB=1` is explicit.
|
|
47
|
+
|
|
48
|
+
Server-only. `AsyncLocalStorage` and PGlite are Node APIs, and Prisma is not
|
|
49
|
+
Edge-safe, so nothing here may be imported (directly or transitively) from
|
|
50
|
+
middleware or an Edge-runtime route.
|
|
51
|
+
|
|
52
|
+
## The plugin seam
|
|
53
|
+
|
|
54
|
+
Packages that own persisted models keep the model file **and its migrations** in
|
|
55
|
+
their own folder, and this package pulls them in:
|
|
56
|
+
|
|
57
|
+
| Owner | Partial | Pulled in by |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| `@12-apps/entity-lifecycle` | `entity-lifecycle.prisma` | `scripts/sync-lifecycle-schema.mjs` |
|
|
60
|
+
| `@12-apps/product-research` | `product-research.prisma` | `scripts/sync-research-schema.mjs` |
|
|
61
|
+
| `@12-apps/shift` | `shift.prisma` | `scripts/sync-shift-schema.mjs` |
|
|
62
|
+
| `@12-apps/jobs` | `jobs.prisma` | `scripts/sync-jobs-schema.mjs` |
|
|
63
|
+
| `@12-apps/payments-backend` | `payments.prisma` | committed symlink |
|
|
64
|
+
| `@12-apps/report-builder` | `report-builder.prisma` | committed symlink |
|
|
65
|
+
|
|
66
|
+
Migrations travel separately, through `scripts/sync-prisma-plugins.mjs`, which
|
|
67
|
+
discovers every plugin-owned `migrations` directory **structurally** rather than
|
|
68
|
+
from a hardcoded list.
|
|
69
|
+
|
|
70
|
+
`prisma/schema/schema.prisma` is **datasource + generator only** — this repo
|
|
71
|
+
deliberately owns no domain models, and no seed command either. The consuming
|
|
72
|
+
application supplies both.
|
|
73
|
+
|
|
74
|
+
Two rules that came from production incidents, gated by `package.test.ts`:
|
|
75
|
+
|
|
76
|
+
- **Migrations are copied, never symlinked.** Prisma enumerates the migrations
|
|
77
|
+
folder with `lstat`, so a symlinked migration reports `isDirectory() === false`
|
|
78
|
+
and is silently skipped — a green deploy that changed no schema.
|
|
79
|
+
- **A partial's owning package must be a declared workspace dependency.**
|
|
80
|
+
`turbo prune` copies only what the dependency graph reaches; an undeclared
|
|
81
|
+
owner is dropped from the build context, the committed partial's source
|
|
82
|
+
vanishes, and its sync script exits 1 during the image build.
|
|
83
|
+
|
|
84
|
+
## Scripts
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pnpm --filter @12-apps/prisma prisma:generate # sync (check mode) + generate
|
|
88
|
+
pnpm --filter @12-apps/prisma prisma:migrate # prisma migrate dev
|
|
89
|
+
pnpm --filter @12-apps/prisma prisma:sync-plugins # repair plugin migration copies
|
|
90
|
+
pnpm --filter @12-apps/prisma prisma:sync-lifecycle # repair one partial
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`build` and `prisma:generate` run every sync in `--check` mode: they **verify**
|
|
94
|
+
the committed state and never repair it, so a CI gate can never assert against
|
|
95
|
+
a tree it just fixed. Repair is always explicit.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request "who is acting" context (FUT-168), backed by Node's
|
|
3
|
+
* AsyncLocalStorage. The auth layer sets the current admin's `users.id` once a
|
|
4
|
+
* request is authorized; the Prisma audit extension (see `audit-extension.ts`)
|
|
5
|
+
* reads it to auto-stamp `created_by` / `updated_by` on tracked models — so no
|
|
6
|
+
* repository signature or call site has to thread the actor through by hand.
|
|
7
|
+
*
|
|
8
|
+
* FUT-152 enriches the context with the ROLE and SCOPE the request was
|
|
9
|
+
* authorized under, so audit entries can record not just who acted but under
|
|
10
|
+
* which authority — populated by the same guards that stamp the user id.
|
|
11
|
+
*
|
|
12
|
+
* FUT-458 adds a SECOND identity: the subject a request is being rendered as
|
|
13
|
+
* while a super-admin impersonation or a "Ver como" preview is live. It is
|
|
14
|
+
* stored as a PAIR — the subject plus the real human behind it — and the
|
|
15
|
+
* second half of that pair is written by this module alone. See
|
|
16
|
+
* {@link ActorAttributionSnapshot.realUserId} for why the real human cannot
|
|
17
|
+
* simply be read back out of {@link ActorContext.userId}.
|
|
18
|
+
*
|
|
19
|
+
* Server-only: AsyncLocalStorage is a Node API. Never import from the Edge
|
|
20
|
+
* middleware runtime.
|
|
21
|
+
*/
|
|
22
|
+
/** Role/scope authority attribution a caller may STAMP (FUT-152). */
|
|
23
|
+
export interface ActorAttribution {
|
|
24
|
+
/** The role name the request was authorized under (e.g. `ADMIN`), if known. */
|
|
25
|
+
role?: string;
|
|
26
|
+
/** The scope the authorization decision was made in (tenant id / `GLOBAL`). */
|
|
27
|
+
scope?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The DB `users.id` this request is being rendered AS (FUT-458) — a
|
|
30
|
+
* super-admin impersonation target, or a "Ver como" previewed member.
|
|
31
|
+
*
|
|
32
|
+
* NEVER the actor: {@link ActorContext.userId} stays the real human whose
|
|
33
|
+
* credentials authorized the request, and this is recorded ALONGSIDE it. The
|
|
34
|
+
* audit trail must be able to answer "who really did this" and "who did the
|
|
35
|
+
* screen claim to be" independently, and a single field cannot.
|
|
36
|
+
*
|
|
37
|
+
* `undefined` leaves an existing value untouched (see {@link setActor}'s merge
|
|
38
|
+
* rule); pass `null` to CLEAR it explicitly. Merge semantics make the
|
|
39
|
+
* distinction load-bearing here — an impersonation that cannot be cleared
|
|
40
|
+
* would leak onto every later write in the same request.
|
|
41
|
+
*/
|
|
42
|
+
onBehalfOfUserId?: string | null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* What {@link getActorAttribution} hands back: everything a caller may stamp,
|
|
46
|
+
* plus the one field only this module ever writes.
|
|
47
|
+
*/
|
|
48
|
+
export interface ActorAttributionSnapshot extends ActorAttribution {
|
|
49
|
+
/**
|
|
50
|
+
* The REAL human behind a live impersonation (FUT-458) — captured from the
|
|
51
|
+
* same stamp that declared it, and absent/`null` when no impersonation is
|
|
52
|
+
* live. Deliberately NOT part of {@link ActorAttribution}: a caller cannot
|
|
53
|
+
* pass it, only this module derives it.
|
|
54
|
+
*
|
|
55
|
+
* Why it exists rather than "just read {@link ActorContext.userId}": that
|
|
56
|
+
* field is LAST-WRITE-WINS, and about sixty route bodies (plus
|
|
57
|
+
* `apps/web/lib/api/tenant.ts`) call `setActor(grant.userId, …)` themselves
|
|
58
|
+
* instead of going through the impersonation-aware stamp in
|
|
59
|
+
* `apps/web/lib/rbac/guards.ts`. While a session is impersonated the tenant
|
|
60
|
+
* guard resolves that grant for the EFFECTIVE subject, so those calls
|
|
61
|
+
* re-stamp `userId` with the person being impersonated — and an audit row
|
|
62
|
+
* derived from it then reads as though the impersonated person did the thing
|
|
63
|
+
* themselves. That is precisely the mis-attribution the epic calls
|
|
64
|
+
* unrecoverable, and `audit_logs` is append-only, so nothing can put it
|
|
65
|
+
* right afterwards.
|
|
66
|
+
*
|
|
67
|
+
* Editing those sixty call sites would fix today's tree and rot the moment
|
|
68
|
+
* someone writes the sixty-first, so the invariant is enforced HERE instead:
|
|
69
|
+
* the real human is recorded ONCE, by the stamp that knows both halves, and
|
|
70
|
+
* an unaware `setActor(someId)` has no way to reach it — it moves only when
|
|
71
|
+
* the impersonation itself is re-declared or cleared. The audit writer
|
|
72
|
+
* (`apps/web/lib/audit/audit.ts`) prefers it over `userId` whenever a live
|
|
73
|
+
* impersonation is present, which is what makes a plain re-stamp harmless.
|
|
74
|
+
*
|
|
75
|
+
* `userId` is left alone on purpose: it also feeds `created_by`/`updated_by`
|
|
76
|
+
* via the audit extension, where "the id this request is acting under" is a
|
|
77
|
+
* different (and mutable, therefore correctable) question from "who is
|
|
78
|
+
* answerable for this append-only row".
|
|
79
|
+
*/
|
|
80
|
+
realUserId?: string | null;
|
|
81
|
+
}
|
|
82
|
+
export interface ActorContext extends ActorAttributionSnapshot {
|
|
83
|
+
/** The acting admin's DB `users.id`, stamped onto created_by/updated_by. */
|
|
84
|
+
userId: string;
|
|
85
|
+
}
|
|
86
|
+
/** Run `fn` with `userId` as the current actor. Nested calls override. */
|
|
87
|
+
export declare const runWithActor: <T>(userId: string, fn: () => T, attribution?: ActorAttribution) => T;
|
|
88
|
+
/**
|
|
89
|
+
* Establish an EMPTY actor scope for one request and run `fn` inside it — the
|
|
90
|
+
* request-boundary bootstrap (`createRouteHandler` wraps every handler in it).
|
|
91
|
+
*
|
|
92
|
+
* Why it must exist: {@link setActor} inside an AWAITED guard uses `enterWith`,
|
|
93
|
+
* which only applies to the guard's own async continuation — the CALLER resumes
|
|
94
|
+
* with the context it captured before the call, so the stamp silently vanishes
|
|
95
|
+
* and every audit entry reads "system". With a scope established here,
|
|
96
|
+
* `setActor` MUTATES the shared context object instead, which every frame of
|
|
97
|
+
* the request's async tree observes — stamps from arbitrarily deep guards
|
|
98
|
+
* survive back into the handler and its repositories.
|
|
99
|
+
*/
|
|
100
|
+
export declare const runWithActorScope: <T>(fn: () => T) => T;
|
|
101
|
+
/**
|
|
102
|
+
* Stamp the current actor for the rest of this request. A falsy id (e.g. the
|
|
103
|
+
* superadmin env-grant carries no DB user id) is ignored so nothing is ever
|
|
104
|
+
* stamped with an empty string. Attribution fields MERGE — only the fields
|
|
105
|
+
* passed are updated — so a guard that knows just the scope doesn't erase a
|
|
106
|
+
* role a caller stamped (or vice versa). Inside a {@link runWithActorScope}
|
|
107
|
+
* boundary the stamp mutates the shared context (survives caller awaits);
|
|
108
|
+
* without one it falls back to `enterWith` (same-context callers only).
|
|
109
|
+
*/
|
|
110
|
+
export declare const setActor: (userId: string, attribution?: ActorAttribution) => void;
|
|
111
|
+
/** The current actor's `users.id`, or undefined when no actor is set. */
|
|
112
|
+
export declare const getActorUserId: () => string | undefined;
|
|
113
|
+
/**
|
|
114
|
+
* The current actor's role/scope attribution (FUT-152) plus the impersonation
|
|
115
|
+
* PAIR (FUT-458) — the subject the request is rendered as, and the real human
|
|
116
|
+
* behind it — if stamped. Every field is `undefined` when nothing stamped it:
|
|
117
|
+
* the audit writer normalizes that to NULL at the row, so the distinction
|
|
118
|
+
* between "never stamped" and "explicitly cleared" stays here, where
|
|
119
|
+
* {@link setActor}'s merge rule needs it, and never leaks into a column.
|
|
120
|
+
*
|
|
121
|
+
* Both halves of the pair are returned together, and consumers must read them
|
|
122
|
+
* together: `onBehalfOfUserId` alone says an impersonation was *declared*,
|
|
123
|
+
* `realUserId` says who is answerable for it. A consumer that sees one without
|
|
124
|
+
* the other is looking at a context nothing in production can produce, and
|
|
125
|
+
* should treat the session as NOT impersonated rather than guess.
|
|
126
|
+
*/
|
|
127
|
+
export declare const getActorAttribution: () => ActorAttributionSnapshot;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Per-request "who is acting" context (FUT-168), backed by Node's
|
|
4
|
+
* AsyncLocalStorage. The auth layer sets the current admin's `users.id` once a
|
|
5
|
+
* request is authorized; the Prisma audit extension (see `audit-extension.ts`)
|
|
6
|
+
* reads it to auto-stamp `created_by` / `updated_by` on tracked models — so no
|
|
7
|
+
* repository signature or call site has to thread the actor through by hand.
|
|
8
|
+
*
|
|
9
|
+
* FUT-152 enriches the context with the ROLE and SCOPE the request was
|
|
10
|
+
* authorized under, so audit entries can record not just who acted but under
|
|
11
|
+
* which authority — populated by the same guards that stamp the user id.
|
|
12
|
+
*
|
|
13
|
+
* FUT-458 adds a SECOND identity: the subject a request is being rendered as
|
|
14
|
+
* while a super-admin impersonation or a "Ver como" preview is live. It is
|
|
15
|
+
* stored as a PAIR — the subject plus the real human behind it — and the
|
|
16
|
+
* second half of that pair is written by this module alone. See
|
|
17
|
+
* {@link ActorAttributionSnapshot.realUserId} for why the real human cannot
|
|
18
|
+
* simply be read back out of {@link ActorContext.userId}.
|
|
19
|
+
*
|
|
20
|
+
* Server-only: AsyncLocalStorage is a Node API. Never import from the Edge
|
|
21
|
+
* middleware runtime.
|
|
22
|
+
*/
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.getActorAttribution = exports.getActorUserId = exports.setActor = exports.runWithActorScope = exports.runWithActor = void 0;
|
|
25
|
+
const node_async_hooks_1 = require("node:async_hooks");
|
|
26
|
+
// Kept on globalThis so Next dev / Turbopack hot-reload (which re-evaluates this
|
|
27
|
+
// module) can't create a second store whose context is invisible to closures
|
|
28
|
+
// captured against the first.
|
|
29
|
+
const globalStore = globalThis;
|
|
30
|
+
const store = () => (globalStore.__futurePayActorStore ??= new node_async_hooks_1.AsyncLocalStorage());
|
|
31
|
+
/**
|
|
32
|
+
* The REAL human behind `onBehalfOfUserId`, derived (never accepted) from the
|
|
33
|
+
* stamp that declares the impersonation (FUT-458).
|
|
34
|
+
*
|
|
35
|
+
* `userId` is that human by construction: the only stamp in the codebase that
|
|
36
|
+
* passes a non-null `onBehalfOfUserId` is `stampActor` in
|
|
37
|
+
* `apps/web/lib/rbac/guards.ts`, which hands over the real id and the subject
|
|
38
|
+
* in the SAME call. That co-location is the whole reason the pair can be
|
|
39
|
+
* trusted — nothing else knows both halves at once, so nothing else can forge
|
|
40
|
+
* one.
|
|
41
|
+
*
|
|
42
|
+
* Clearing is symmetric: ending an impersonation drops BOTH halves. A stale
|
|
43
|
+
* real id left behind would make every later write in the request look as
|
|
44
|
+
* though it still carried a hidden second identity.
|
|
45
|
+
*/
|
|
46
|
+
const realActorFor = (userId, onBehalfOfUserId) => onBehalfOfUserId === null ? null : userId;
|
|
47
|
+
/**
|
|
48
|
+
* A fresh context for `userId`. The impersonation pair is derived only when
|
|
49
|
+
* the stamp expressed an opinion — `undefined` means "no opinion" everywhere
|
|
50
|
+
* in this module, and must not be written as a value.
|
|
51
|
+
*/
|
|
52
|
+
const freshContext = (userId, attribution) => ({
|
|
53
|
+
userId,
|
|
54
|
+
...attribution,
|
|
55
|
+
...(attribution.onBehalfOfUserId !== undefined
|
|
56
|
+
? { realUserId: realActorFor(userId, attribution.onBehalfOfUserId) }
|
|
57
|
+
: {}),
|
|
58
|
+
});
|
|
59
|
+
/** Run `fn` with `userId` as the current actor. Nested calls override. */
|
|
60
|
+
const runWithActor = (userId, fn, attribution = {}) => store().run(freshContext(userId, attribution), fn);
|
|
61
|
+
exports.runWithActor = runWithActor;
|
|
62
|
+
/**
|
|
63
|
+
* Establish an EMPTY actor scope for one request and run `fn` inside it — the
|
|
64
|
+
* request-boundary bootstrap (`createRouteHandler` wraps every handler in it).
|
|
65
|
+
*
|
|
66
|
+
* Why it must exist: {@link setActor} inside an AWAITED guard uses `enterWith`,
|
|
67
|
+
* which only applies to the guard's own async continuation — the CALLER resumes
|
|
68
|
+
* with the context it captured before the call, so the stamp silently vanishes
|
|
69
|
+
* and every audit entry reads "system". With a scope established here,
|
|
70
|
+
* `setActor` MUTATES the shared context object instead, which every frame of
|
|
71
|
+
* the request's async tree observes — stamps from arbitrarily deep guards
|
|
72
|
+
* survive back into the handler and its repositories.
|
|
73
|
+
*/
|
|
74
|
+
const runWithActorScope = (fn) => store().run({ userId: "" }, fn);
|
|
75
|
+
exports.runWithActorScope = runWithActorScope;
|
|
76
|
+
/**
|
|
77
|
+
* Stamp the current actor for the rest of this request. A falsy id (e.g. the
|
|
78
|
+
* superadmin env-grant carries no DB user id) is ignored so nothing is ever
|
|
79
|
+
* stamped with an empty string. Attribution fields MERGE — only the fields
|
|
80
|
+
* passed are updated — so a guard that knows just the scope doesn't erase a
|
|
81
|
+
* role a caller stamped (or vice versa). Inside a {@link runWithActorScope}
|
|
82
|
+
* boundary the stamp mutates the shared context (survives caller awaits);
|
|
83
|
+
* without one it falls back to `enterWith` (same-context callers only).
|
|
84
|
+
*/
|
|
85
|
+
const setActor = (userId, attribution = {}) => {
|
|
86
|
+
if (!userId)
|
|
87
|
+
return;
|
|
88
|
+
const current = store().getStore();
|
|
89
|
+
if (current) {
|
|
90
|
+
current.userId = userId;
|
|
91
|
+
if (attribution.role !== undefined)
|
|
92
|
+
current.role = attribution.role;
|
|
93
|
+
if (attribution.scope !== undefined)
|
|
94
|
+
current.scope = attribution.scope;
|
|
95
|
+
// FUT-458 — same merge rule, and the reason it has to be `!== undefined`
|
|
96
|
+
// rather than a truthiness check: ENDING an impersonation is expressed as
|
|
97
|
+
// `null`, and a truthy guard would treat that clear as "no opinion" and
|
|
98
|
+
// leave the previous target standing for the rest of the request.
|
|
99
|
+
//
|
|
100
|
+
// Note what this branch does NOT do: an unaware stamp — one that passes no
|
|
101
|
+
// `onBehalfOfUserId` at all — moves `userId` and nothing else. The
|
|
102
|
+
// impersonation pair survives it untouched, which is the property the
|
|
103
|
+
// audit trail is built on (see `ActorAttributionSnapshot.realUserId`).
|
|
104
|
+
if (attribution.onBehalfOfUserId !== undefined) {
|
|
105
|
+
current.onBehalfOfUserId = attribution.onBehalfOfUserId;
|
|
106
|
+
current.realUserId = realActorFor(userId, attribution.onBehalfOfUserId);
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
store().enterWith(freshContext(userId, attribution));
|
|
111
|
+
};
|
|
112
|
+
exports.setActor = setActor;
|
|
113
|
+
/** The current actor's `users.id`, or undefined when no actor is set. */
|
|
114
|
+
const getActorUserId = () => store().getStore()?.userId || undefined;
|
|
115
|
+
exports.getActorUserId = getActorUserId;
|
|
116
|
+
/**
|
|
117
|
+
* The current actor's role/scope attribution (FUT-152) plus the impersonation
|
|
118
|
+
* PAIR (FUT-458) — the subject the request is rendered as, and the real human
|
|
119
|
+
* behind it — if stamped. Every field is `undefined` when nothing stamped it:
|
|
120
|
+
* the audit writer normalizes that to NULL at the row, so the distinction
|
|
121
|
+
* between "never stamped" and "explicitly cleared" stays here, where
|
|
122
|
+
* {@link setActor}'s merge rule needs it, and never leaks into a column.
|
|
123
|
+
*
|
|
124
|
+
* Both halves of the pair are returned together, and consumers must read them
|
|
125
|
+
* together: `onBehalfOfUserId` alone says an impersonation was *declared*,
|
|
126
|
+
* `realUserId` says who is answerable for it. A consumer that sees one without
|
|
127
|
+
* the other is looking at a context nothing in production can produce, and
|
|
128
|
+
* should treat the session as NOT impersonated rather than guess.
|
|
129
|
+
*/
|
|
130
|
+
const getActorAttribution = () => {
|
|
131
|
+
const context = store().getStore();
|
|
132
|
+
return {
|
|
133
|
+
role: context?.role,
|
|
134
|
+
scope: context?.scope,
|
|
135
|
+
onBehalfOfUserId: context?.onBehalfOfUserId,
|
|
136
|
+
realUserId: context?.realUserId,
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
exports.getActorAttribution = getActorAttribution;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prisma client extension that makes the audit log physically append-only at
|
|
3
|
+
* the client layer (FUT-209).
|
|
4
|
+
*
|
|
5
|
+
* The audit trail's value rests on immutability: an entry that can be edited
|
|
6
|
+
* or deleted after the fact proves nothing. There is no code path that should
|
|
7
|
+
* ever mutate `audit_logs`, so every update/upsert/delete delegate on the
|
|
8
|
+
* model throws — including the batch variants — before reaching the database.
|
|
9
|
+
* The house pattern for append-only tables (entity_versions) relies on code
|
|
10
|
+
* discipline alone; the audit log gets the harder guard because it exists
|
|
11
|
+
* precisely for the cases where discipline failed.
|
|
12
|
+
*
|
|
13
|
+
* The ONLY sanctioned removal is the retention sweep (12-month policy), which
|
|
14
|
+
* runs raw SQL over `created_at` — `$executeRaw` bypasses model delegates and
|
|
15
|
+
* is untouched by this extension. Test truncation uses raw SQL for the same
|
|
16
|
+
* reason.
|
|
17
|
+
*/
|
|
18
|
+
import type { PrismaClient } from '@prisma/client';
|
|
19
|
+
/** Thrown when code attempts to mutate an append-only model. */
|
|
20
|
+
export declare class AppendOnlyViolationError extends Error {
|
|
21
|
+
constructor(model: string, operation: string);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Wrap a client so mutating operations on append-only models throw.
|
|
25
|
+
* Returns the client typed as {@link PrismaClient}: the extension only adds
|
|
26
|
+
* query middleware (no new delegates), so every existing call site stays valid.
|
|
27
|
+
*/
|
|
28
|
+
export declare function applyAppendOnlyGuard(client: PrismaClient): PrismaClient;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Prisma client extension that makes the audit log physically append-only at
|
|
4
|
+
* the client layer (FUT-209).
|
|
5
|
+
*
|
|
6
|
+
* The audit trail's value rests on immutability: an entry that can be edited
|
|
7
|
+
* or deleted after the fact proves nothing. There is no code path that should
|
|
8
|
+
* ever mutate `audit_logs`, so every update/upsert/delete delegate on the
|
|
9
|
+
* model throws — including the batch variants — before reaching the database.
|
|
10
|
+
* The house pattern for append-only tables (entity_versions) relies on code
|
|
11
|
+
* discipline alone; the audit log gets the harder guard because it exists
|
|
12
|
+
* precisely for the cases where discipline failed.
|
|
13
|
+
*
|
|
14
|
+
* The ONLY sanctioned removal is the retention sweep (12-month policy), which
|
|
15
|
+
* runs raw SQL over `created_at` — `$executeRaw` bypasses model delegates and
|
|
16
|
+
* is untouched by this extension. Test truncation uses raw SQL for the same
|
|
17
|
+
* reason.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.AppendOnlyViolationError = void 0;
|
|
21
|
+
exports.applyAppendOnlyGuard = applyAppendOnlyGuard;
|
|
22
|
+
/** Prisma model names that must never be updated or deleted via the model API. */
|
|
23
|
+
const APPEND_ONLY_MODELS = new Set(['AuditLog']);
|
|
24
|
+
/** Thrown when code attempts to mutate an append-only model. */
|
|
25
|
+
class AppendOnlyViolationError extends Error {
|
|
26
|
+
constructor(model, operation) {
|
|
27
|
+
super(`${model} is append-only: "${operation}" is not allowed. ` +
|
|
28
|
+
'Audit entries are immutable; retention sweeps use raw SQL.');
|
|
29
|
+
this.name = 'AppendOnlyViolationError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
exports.AppendOnlyViolationError = AppendOnlyViolationError;
|
|
33
|
+
/** Throw for an append-only model, else pass the call through untouched. */
|
|
34
|
+
function guard(model, operation, args, query) {
|
|
35
|
+
if (APPEND_ONLY_MODELS.has(model)) {
|
|
36
|
+
throw new AppendOnlyViolationError(model, operation);
|
|
37
|
+
}
|
|
38
|
+
return query(args);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Wrap a client so mutating operations on append-only models throw.
|
|
42
|
+
* Returns the client typed as {@link PrismaClient}: the extension only adds
|
|
43
|
+
* query middleware (no new delegates), so every existing call site stays valid.
|
|
44
|
+
*/
|
|
45
|
+
function applyAppendOnlyGuard(client) {
|
|
46
|
+
const extended = client.$extends({
|
|
47
|
+
name: 'appendOnlyGuard',
|
|
48
|
+
query: {
|
|
49
|
+
$allModels: {
|
|
50
|
+
update({ model, args, query }) {
|
|
51
|
+
return guard(model, 'update', args, query);
|
|
52
|
+
},
|
|
53
|
+
updateMany({ model, args, query }) {
|
|
54
|
+
return guard(model, 'updateMany', args, query);
|
|
55
|
+
},
|
|
56
|
+
updateManyAndReturn({ model, args, query }) {
|
|
57
|
+
return guard(model, 'updateManyAndReturn', args, query);
|
|
58
|
+
},
|
|
59
|
+
upsert({ model, args, query }) {
|
|
60
|
+
return guard(model, 'upsert', args, query);
|
|
61
|
+
},
|
|
62
|
+
delete({ model, args, query }) {
|
|
63
|
+
return guard(model, 'delete', args, query);
|
|
64
|
+
},
|
|
65
|
+
deleteMany({ model, args, query }) {
|
|
66
|
+
return guard(model, 'deleteMany', args, query);
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
return extended;
|
|
72
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prisma client extension that auto-stamps change attribution (FUT-168).
|
|
3
|
+
*
|
|
4
|
+
* For the tracked models it fills `createdBy` on create and `updatedBy` on
|
|
5
|
+
* create/update from the current actor (see `actor-context.ts`), so repositories
|
|
6
|
+
* and actions never pass an actor id explicitly. A write with no actor in scope
|
|
7
|
+
* (system/seed/unauthenticated) is left untouched — the columns stay NULL.
|
|
8
|
+
*
|
|
9
|
+
* Only fields the caller did not already set are filled, so an explicit override
|
|
10
|
+
* always wins.
|
|
11
|
+
*/
|
|
12
|
+
import type { PrismaClient } from '@prisma/client';
|
|
13
|
+
/**
|
|
14
|
+
* Wrap a client so tracked-model writes are attributed to the current actor.
|
|
15
|
+
* Returns the client typed as {@link PrismaClient}: the extension only adds query
|
|
16
|
+
* middleware (no new delegates), so every existing call site stays valid.
|
|
17
|
+
*/
|
|
18
|
+
export declare function applyAuditStamps(client: PrismaClient): PrismaClient;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Prisma client extension that auto-stamps change attribution (FUT-168).
|
|
4
|
+
*
|
|
5
|
+
* For the tracked models it fills `createdBy` on create and `updatedBy` on
|
|
6
|
+
* create/update from the current actor (see `actor-context.ts`), so repositories
|
|
7
|
+
* and actions never pass an actor id explicitly. A write with no actor in scope
|
|
8
|
+
* (system/seed/unauthenticated) is left untouched — the columns stay NULL.
|
|
9
|
+
*
|
|
10
|
+
* Only fields the caller did not already set are filled, so an explicit override
|
|
11
|
+
* always wins.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.applyAuditStamps = applyAuditStamps;
|
|
15
|
+
const actor_context_1 = require("./actor-context");
|
|
16
|
+
const search_normalize_1 = require("./search-normalize");
|
|
17
|
+
/** Prisma model names that carry `created_by`/`updated_by` + `search_name`. */
|
|
18
|
+
const TRACKED_MODELS = new Set([
|
|
19
|
+
'MenuItem',
|
|
20
|
+
'InventoryItem',
|
|
21
|
+
'ProductCategory',
|
|
22
|
+
'Supplier',
|
|
23
|
+
'Discount',
|
|
24
|
+
]);
|
|
25
|
+
/** Fill created_by + updated_by on a create payload (without clobbering overrides). */
|
|
26
|
+
function stampCreate(data, userId) {
|
|
27
|
+
if (data.createdBy === undefined)
|
|
28
|
+
data.createdBy = userId;
|
|
29
|
+
if (data.updatedBy === undefined)
|
|
30
|
+
data.updatedBy = userId;
|
|
31
|
+
}
|
|
32
|
+
/** Fill updated_by on an update payload. */
|
|
33
|
+
function stampUpdate(data, userId) {
|
|
34
|
+
if (data.updatedBy === undefined)
|
|
35
|
+
data.updatedBy = userId;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Keep `search_name` in sync with `name` for accent/case-insensitive search
|
|
39
|
+
* (FUT-168). Applied on every tracked write that sets `name` — including
|
|
40
|
+
* system/seed writes with no actor — so the column never drifts. A caller that
|
|
41
|
+
* sets `searchName` explicitly wins.
|
|
42
|
+
*/
|
|
43
|
+
function stampSearchName(data) {
|
|
44
|
+
if (typeof data.name === 'string' && data.searchName === undefined) {
|
|
45
|
+
data.searchName = (0, search_normalize_1.normalizeSearchText)(data.name);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Wrap a client so tracked-model writes are attributed to the current actor.
|
|
50
|
+
* Returns the client typed as {@link PrismaClient}: the extension only adds query
|
|
51
|
+
* middleware (no new delegates), so every existing call site stays valid.
|
|
52
|
+
*/
|
|
53
|
+
function applyAuditStamps(client) {
|
|
54
|
+
const extended = client.$extends({
|
|
55
|
+
name: 'auditStamps',
|
|
56
|
+
query: {
|
|
57
|
+
$allModels: {
|
|
58
|
+
create({ model, args, query }) {
|
|
59
|
+
if (TRACKED_MODELS.has(model) && args.data) {
|
|
60
|
+
const data = args.data;
|
|
61
|
+
stampSearchName(data);
|
|
62
|
+
const userId = (0, actor_context_1.getActorUserId)();
|
|
63
|
+
if (userId)
|
|
64
|
+
stampCreate(data, userId);
|
|
65
|
+
}
|
|
66
|
+
return query(args);
|
|
67
|
+
},
|
|
68
|
+
createMany({ model, args, query }) {
|
|
69
|
+
if (TRACKED_MODELS.has(model) && args.data) {
|
|
70
|
+
const userId = (0, actor_context_1.getActorUserId)();
|
|
71
|
+
const rows = Array.isArray(args.data) ? args.data : [args.data];
|
|
72
|
+
rows.forEach((row) => {
|
|
73
|
+
const data = row;
|
|
74
|
+
stampSearchName(data);
|
|
75
|
+
if (userId)
|
|
76
|
+
stampCreate(data, userId);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return query(args);
|
|
80
|
+
},
|
|
81
|
+
update({ model, args, query }) {
|
|
82
|
+
if (TRACKED_MODELS.has(model) && args.data) {
|
|
83
|
+
const data = args.data;
|
|
84
|
+
stampSearchName(data);
|
|
85
|
+
const userId = (0, actor_context_1.getActorUserId)();
|
|
86
|
+
if (userId)
|
|
87
|
+
stampUpdate(data, userId);
|
|
88
|
+
}
|
|
89
|
+
return query(args);
|
|
90
|
+
},
|
|
91
|
+
updateMany({ model, args, query }) {
|
|
92
|
+
if (TRACKED_MODELS.has(model) && args.data) {
|
|
93
|
+
const data = args.data;
|
|
94
|
+
stampSearchName(data);
|
|
95
|
+
const userId = (0, actor_context_1.getActorUserId)();
|
|
96
|
+
if (userId)
|
|
97
|
+
stampUpdate(data, userId);
|
|
98
|
+
}
|
|
99
|
+
return query(args);
|
|
100
|
+
},
|
|
101
|
+
upsert({ model, args, query }) {
|
|
102
|
+
if (TRACKED_MODELS.has(model)) {
|
|
103
|
+
const userId = (0, actor_context_1.getActorUserId)();
|
|
104
|
+
if (args.create) {
|
|
105
|
+
const data = args.create;
|
|
106
|
+
stampSearchName(data);
|
|
107
|
+
if (userId)
|
|
108
|
+
stampCreate(data, userId);
|
|
109
|
+
}
|
|
110
|
+
if (args.update) {
|
|
111
|
+
const data = args.update;
|
|
112
|
+
stampSearchName(data);
|
|
113
|
+
if (userId)
|
|
114
|
+
stampUpdate(data, userId);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return query(args);
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
return extended;
|
|
123
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prisma client singleton.
|
|
3
|
+
*
|
|
4
|
+
* Default mode builds a PostgreSQL-backed `PrismaClient` (the real / production
|
|
5
|
+
* database, driven by `DATABASE_URL`). When a PGlite flag is present in the
|
|
6
|
+
* environment the same client is built on the community `pglite-prisma-adapter`
|
|
7
|
+
* so PGlite (WASM Postgres) becomes the app runtime database for local dev and
|
|
8
|
+
* Playwright e2e — no Docker Postgres required.
|
|
9
|
+
*
|
|
10
|
+
* PGlite mode is selected by ANY of:
|
|
11
|
+
* - `USE_FILE_DB=1` (or `true`) → in-memory, unless a dir is also set
|
|
12
|
+
* - `PGLITE_DATA_DIR=<dir>` → file-backed at <dir>
|
|
13
|
+
* - `DATABASE_URL` starting with `pglite:` → `pglite:memory` or `pglite:<dir>`
|
|
14
|
+
*
|
|
15
|
+
* Production safety: when `NODE_ENV=production` the database is ALWAYS real
|
|
16
|
+
* PostgreSQL unless `USE_FILE_DB=1` is set explicitly — so a stray
|
|
17
|
+
* `PGLITE_DATA_DIR` / `pglite:` URL can never silently point prod at a throwaway
|
|
18
|
+
* WASM DB. `USE_FILE_DB=0` forces real PostgreSQL everywhere (dev/e2e opt-out).
|
|
19
|
+
*
|
|
20
|
+
* The generated `PrismaClient` type is re-exported below so consumers stay fully
|
|
21
|
+
* typed without any hand-written stub interfaces. Server-only: never import this
|
|
22
|
+
* (directly or transitively) from middleware or an Edge-runtime route — PGlite
|
|
23
|
+
* needs Node's filesystem/WASM and Prisma is not Edge-safe.
|
|
24
|
+
*/
|
|
25
|
+
export type { PrismaClient } from '@prisma/client';
|
|
26
|
+
import type { PrismaClient } from '@prisma/client';
|
|
27
|
+
export { AppendOnlyViolationError } from './append-only-extension';
|
|
28
|
+
export { getActorAttribution, getActorUserId, runWithActor, runWithActorScope, setActor, type ActorAttribution, type ActorAttributionSnapshot, type ActorContext, } from './actor-context';
|
|
29
|
+
export { normalizeSearchText } from './search-normalize';
|
|
30
|
+
/**
|
|
31
|
+
* Get or create the Prisma client instance.
|
|
32
|
+
*
|
|
33
|
+
* Lazy and memoised: concurrent callers share a single in-flight init promise,
|
|
34
|
+
* so only one client (and at most one PGlite instance) is ever created. A
|
|
35
|
+
* missing generated client surfaces an actionable error rather than a hard
|
|
36
|
+
* module-load failure.
|
|
37
|
+
*/
|
|
38
|
+
export declare const getPrismaClient: () => Promise<PrismaClient>;
|
|
39
|
+
/**
|
|
40
|
+
* Set a custom Prisma client instance (for testing).
|
|
41
|
+
*/
|
|
42
|
+
export declare const setPrismaClient: (client: PrismaClient) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Reset the Prisma client instance (for testing).
|
|
45
|
+
*/
|
|
46
|
+
export declare const resetPrismaClient: () => void;
|