esoul-sdk 0.4.0 → 0.6.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 +135 -24
- package/dist/audience.d.ts +103 -0
- package/dist/audience.js +142 -0
- package/dist/bindings.d.ts +164 -0
- package/dist/bindings.js +163 -0
- package/dist/db/client-core.d.ts +154 -0
- package/dist/db/client-core.js +274 -0
- package/dist/db/compile-rules.d.ts +199 -0
- package/dist/db/compile-rules.js +390 -0
- package/dist/db/memory-client.d.ts +136 -0
- package/dist/db/memory-client.js +323 -0
- package/dist/db/schema-gen.d.ts +103 -0
- package/dist/db/schema-gen.js +329 -0
- package/dist/helpers.d.ts +47 -0
- package/dist/helpers.js +98 -10
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/manifest.d.ts +445 -13
- package/dist/manifest.js +211 -5
- package/dist/roles.d.ts +43 -0
- package/dist/roles.js +56 -0
- package/dist/server.d.ts +165 -0
- package/dist/server.js +80 -0
- package/dist/testing/db.d.ts +69 -0
- package/dist/testing/db.js +94 -0
- package/dist/testing/index.d.ts +14 -0
- package/dist/testing/index.js +9 -0
- package/dist/testing/ops.d.ts +84 -0
- package/dist/testing/ops.js +76 -0
- package/dist/types.d.ts +22 -1
- package/docs/04-tools.md +5 -2
- package/docs/06-server.md +49 -0
- package/docs/07-background-tasks.md +23 -0
- package/docs/10-testing.md +18 -0
- package/docs/12-rules.md +3 -2
- package/docs/13-people-and-access.md +148 -0
- package/docs/14-database.md +115 -0
- package/docs/15-realtime.md +88 -0
- package/docs/16-bindings.md +79 -0
- package/llms-full.txt +679 -28
- package/llms.txt +4 -0
- package/package.json +7 -3
- package/schemas/plugin.schema.json +323 -9
package/dist/bindings.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT STANDS BEHIND A SLOT.
|
|
3
|
+
*
|
|
4
|
+
* An app is an island until it can lean on another one. A storefront that
|
|
5
|
+
* wants real stock either keeps its own products table and guesses, or names
|
|
6
|
+
* a specific inventory app and is married to it forever. Neither is what a
|
|
7
|
+
* person means when they say "use my warehouse for this shop".
|
|
8
|
+
*
|
|
9
|
+
* So an app declares a SLOT and the CONTRACT it needs:
|
|
10
|
+
*
|
|
11
|
+
* "uses": { "stock": { "contract": "stock/v1", "label": "Stock" } }
|
|
12
|
+
*
|
|
13
|
+
* and any app that can do the job declares that it does:
|
|
14
|
+
*
|
|
15
|
+
* "provides": { "stock/v1": { "tools": ["reserve_stock"], "events": ["stock_low"], "models": ["Product"] } }
|
|
16
|
+
*
|
|
17
|
+
* The owner picks which app fills the slot, and the platform checks — at BIND
|
|
18
|
+
* time, once, loudly — that the provider really has every tool, event and
|
|
19
|
+
* table the contract names. Not at call time, where a missing tool is an
|
|
20
|
+
* error in front of a customer.
|
|
21
|
+
*
|
|
22
|
+
* Versions grow by ADDING. A provider on `stock/v2` satisfies a consumer
|
|
23
|
+
* asking for `stock/v1`, because v2 still has everything v1 named; the
|
|
24
|
+
* reverse is refused, because v1 has never heard of what v2 added. That one
|
|
25
|
+
* rule is what lets a storefront keep working while its provider moves on.
|
|
26
|
+
*
|
|
27
|
+
* Pure: no platform, no transport. The bind-time check, the workbench picker
|
|
28
|
+
* and the author's own tests all read this.
|
|
29
|
+
*/
|
|
30
|
+
export function parseContractId(raw) {
|
|
31
|
+
const m = /^([a-z][a-z0-9-]*)\/v(\d+)$/.exec(raw ?? "");
|
|
32
|
+
if (!m)
|
|
33
|
+
return null;
|
|
34
|
+
const version = Number(m[2]);
|
|
35
|
+
return Number.isSafeInteger(version) && version > 0 ? { name: m[1], version } : null;
|
|
36
|
+
}
|
|
37
|
+
export function formatContractId(c) {
|
|
38
|
+
return `${c.name}/v${c.version}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* May this app fill this slot? Every reason it may not, in the author's
|
|
42
|
+
* words — a picker shows them, and a bind refuses on any of them.
|
|
43
|
+
*
|
|
44
|
+
* `contract` is the DEFINITION (what the contract requires). `facts` is what
|
|
45
|
+
* the candidate app really has. A provider's own `provides` block is a claim,
|
|
46
|
+
* and a claim is checked against both.
|
|
47
|
+
*/
|
|
48
|
+
export function checkBinding(args) {
|
|
49
|
+
const { wanted, contract, facts } = args;
|
|
50
|
+
const want = parseContractId(wanted);
|
|
51
|
+
if (!want)
|
|
52
|
+
return { ok: false, reasons: [`"${wanted}" is not a contract id — they look like "stock/v1"`] };
|
|
53
|
+
if (!contract)
|
|
54
|
+
return { ok: false, reasons: [`no contract "${wanted}" is known to this platform`] };
|
|
55
|
+
// The provider's claim: same name, version at least as high. Versions grow
|
|
56
|
+
// by adding, so a v2 provider still does everything v1 promised.
|
|
57
|
+
const claims = Object.keys(facts.provides)
|
|
58
|
+
.map((id) => ({ id, parsed: parseContractId(id) }))
|
|
59
|
+
.filter((c) => !!c.parsed && c.parsed.name === want.name);
|
|
60
|
+
if (!claims.length) {
|
|
61
|
+
return { ok: false, reasons: [`${facts.applicationType} does not provide "${want.name}"`] };
|
|
62
|
+
}
|
|
63
|
+
const usable = claims.filter((c) => c.parsed.version >= want.version).sort((a, b) => a.parsed.version - b.parsed.version)[0];
|
|
64
|
+
if (!usable) {
|
|
65
|
+
const best = claims.map((c) => c.id).join(", ");
|
|
66
|
+
return { ok: false, reasons: [`${facts.applicationType} provides ${best}, which is older than ${wanted} — a slot may be filled by the same version or a newer one, never an older one`] };
|
|
67
|
+
}
|
|
68
|
+
const decl = facts.provides[usable.id] ?? {};
|
|
69
|
+
const reasons = [];
|
|
70
|
+
const check = (kind, required, claimed, real) => {
|
|
71
|
+
for (const name of required) {
|
|
72
|
+
if (!(claimed ?? []).includes(name))
|
|
73
|
+
reasons.push(`${usable.id} does not offer the ${kind} "${name}" that ${wanted} requires`);
|
|
74
|
+
else if (!real.includes(name))
|
|
75
|
+
reasons.push(`${facts.applicationType} claims the ${kind} "${name}" but does not have it`);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
check("tool", contract.tools, decl.tools, facts.tools);
|
|
79
|
+
check("event", contract.events, decl.events, facts.events);
|
|
80
|
+
check("table", contract.models, decl.models, facts.models);
|
|
81
|
+
if (reasons.length)
|
|
82
|
+
return { ok: false, reasons };
|
|
83
|
+
return { ok: true, via: usable.id, tools: decl.tools ?? [], events: decl.events ?? [], models: decl.models ?? [] };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The reducer behind `plugin/binding_set`. Deterministic: setting a slot
|
|
87
|
+
* replaces it, binding an empty nodeId clears it, and an unknown slot is
|
|
88
|
+
* ignored rather than invented — the slots are the manifest's, not the
|
|
89
|
+
* event's.
|
|
90
|
+
*/
|
|
91
|
+
export function reduceBinding(current, event, knownSlots) {
|
|
92
|
+
const slot = typeof event.slot === "string" ? event.slot : "";
|
|
93
|
+
if (!slot || !knownSlots.includes(slot))
|
|
94
|
+
return current;
|
|
95
|
+
const nodeId = typeof event.nodeId === "string" ? event.nodeId : "";
|
|
96
|
+
if (!nodeId) {
|
|
97
|
+
if (!(slot in current))
|
|
98
|
+
return current;
|
|
99
|
+
const { [slot]: _gone, ...rest } = current;
|
|
100
|
+
return rest;
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
...current,
|
|
104
|
+
[slot]: {
|
|
105
|
+
nodeId,
|
|
106
|
+
applicationType: typeof event.applicationType === "string" ? event.applicationType : "",
|
|
107
|
+
via: typeof event.via === "string" ? event.via : "",
|
|
108
|
+
...(typeof event.at === "number" ? { boundAt: event.at } : {}),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** The slots a manifest declares that nothing fills yet — the ones a seam refuses on. */
|
|
113
|
+
export function unfilledRequiredSlots(uses, bindings) {
|
|
114
|
+
return Object.keys(uses ?? {}).filter((slot) => !uses[slot].optional && !bindings[slot]);
|
|
115
|
+
}
|
|
116
|
+
export function bindingEventName(applicationType) {
|
|
117
|
+
return `${applicationType}_binding_set`;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The event definition for one app's slots. `knownSlots` comes from the
|
|
121
|
+
* manifest, so an event naming a slot the app never declared is ignored
|
|
122
|
+
* rather than inventing one.
|
|
123
|
+
*
|
|
124
|
+
* Typed loosely on purpose: the SDK's `EventDefinition` lives in `types.ts`
|
|
125
|
+
* and this module stays free of it, so the same file can be read by the
|
|
126
|
+
* platform, the workbench and an author's tests without dragging the schema
|
|
127
|
+
* types along.
|
|
128
|
+
*/
|
|
129
|
+
export function defineBindingEvent(args) {
|
|
130
|
+
const eventName = bindingEventName(args.applicationType);
|
|
131
|
+
return {
|
|
132
|
+
eventName,
|
|
133
|
+
type: "Client",
|
|
134
|
+
triggerMeta: {
|
|
135
|
+
displayName: "App binding changed",
|
|
136
|
+
description: `Fires when someone points one of this app's slots at another app, or clears it. eventData: {slot, nodeId, applicationType, via}.`,
|
|
137
|
+
sampleVariables: ["event.slot", "event.applicationType"],
|
|
138
|
+
},
|
|
139
|
+
dataCreator: (a) => ({
|
|
140
|
+
eventName,
|
|
141
|
+
eventData: {
|
|
142
|
+
slot: String(a.slot ?? ""),
|
|
143
|
+
nodeId: String(a.nodeId ?? ""),
|
|
144
|
+
applicationType: String(a.applicationType ?? ""),
|
|
145
|
+
via: String(a.via ?? ""),
|
|
146
|
+
at: typeof a.at === "number" ? a.at : Date.now(),
|
|
147
|
+
},
|
|
148
|
+
timestamp: Date.now(),
|
|
149
|
+
workspaceId: a.workspaceId,
|
|
150
|
+
applicationId: a.applicationId || a.nodeId,
|
|
151
|
+
instanceName: a.instanceName,
|
|
152
|
+
chatIdSource: a.chatIdSource,
|
|
153
|
+
}),
|
|
154
|
+
processor: (state, event) => {
|
|
155
|
+
const next = reduceBinding(state._bindings ?? {}, event.eventData ?? {}, args.slots);
|
|
156
|
+
return next === (state._bindings ?? {}) ? state : { ...state, _bindings: next };
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** The bindings an app currently holds, from its folded state. */
|
|
161
|
+
export function bindingsOf(state) {
|
|
162
|
+
return state?._bindings ?? {};
|
|
163
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CLIENT CORE — everything a plugin database client decides, with nothing
|
|
3
|
+
* about where the rows live.
|
|
4
|
+
*
|
|
5
|
+
* Two clients read an app's tables: the in-memory one an author's unit tests
|
|
6
|
+
* run against (and the workbench box), and the Prisma one production runs
|
|
7
|
+
* against. The differential test requires them to agree on every visible set
|
|
8
|
+
* for every viewer after every operation. That is only credible if they do not
|
|
9
|
+
* each re-implement the decisions — so the decisions live here, once, and a
|
|
10
|
+
* client supplies storage: "give me the rows matching this scope", "does a row
|
|
11
|
+
* with these unique values exist", "fetch this ref target".
|
|
12
|
+
*
|
|
13
|
+
* Every function takes the same `Core` handle and REFUSES through the injected
|
|
14
|
+
* `refuse`, so a refusal raised by a test and a refusal raised by production are
|
|
15
|
+
* the same object with the same code, and the package still works with no host
|
|
16
|
+
* attached.
|
|
17
|
+
*
|
|
18
|
+
* The one thing deliberately NOT here is `where` evaluation. Memory evaluates a
|
|
19
|
+
* filter in JS; Prisma hands it to Postgres. Both validate the filter's KEYS
|
|
20
|
+
* here first, and the differential test is what proves the two evaluators
|
|
21
|
+
* agree on the values.
|
|
22
|
+
*/
|
|
23
|
+
import { type CompiledModel, type CompiledRules, type ReadPlan, type RuleViewer, type WritePlan } from "./compile-rules.js";
|
|
24
|
+
export type Row = Record<string, unknown>;
|
|
25
|
+
export type RefuseCode = "forbidden" | "login-required" | "invalid" | "not-bound" | "rate-limited";
|
|
26
|
+
export type Refuse = (code: RefuseCode, message: string, detail?: string) => never;
|
|
27
|
+
/** Standalone default so the package works with no host attached. */
|
|
28
|
+
export declare const defaultRefuse: Refuse;
|
|
29
|
+
export type Across = "owned-instances" | "my-rows";
|
|
30
|
+
export type WriteOp = "create" | "update" | "delete";
|
|
31
|
+
export interface ClientScope {
|
|
32
|
+
workspaceId: string;
|
|
33
|
+
nodeId: string;
|
|
34
|
+
}
|
|
35
|
+
/** What every decision needs. One per (client, model). */
|
|
36
|
+
export interface Core {
|
|
37
|
+
rules: CompiledRules;
|
|
38
|
+
model: CompiledModel;
|
|
39
|
+
viewer: RuleViewer;
|
|
40
|
+
scope: ClientScope;
|
|
41
|
+
refuse: Refuse;
|
|
42
|
+
/** Read across instances — owner-only / own-rows-only, never for writes. */
|
|
43
|
+
across?: Across;
|
|
44
|
+
/** Workspaces this viewer owns, resolved by the PLATFORM, never by the app. */
|
|
45
|
+
ownedWorkspaceIds: string[];
|
|
46
|
+
/** True when another app is reading this one through a binding. */
|
|
47
|
+
viaBinding: boolean;
|
|
48
|
+
}
|
|
49
|
+
export declare const DEFAULT_TAKE = 50;
|
|
50
|
+
export declare const MAX_TAKE = 200;
|
|
51
|
+
export declare const FILTER_OPS: readonly ["in", "notIn", "not", "gt", "gte", "lt", "lte", "contains"];
|
|
52
|
+
export type FilterOp = (typeof FILTER_OPS)[number];
|
|
53
|
+
export declare function bad(c: Core, detail: string): never;
|
|
54
|
+
/**
|
|
55
|
+
* A caller-supplied scope key is REFUSED, never overridden. An app that sets
|
|
56
|
+
* its own `nodeId` is either wrong or trying something, and overriding it
|
|
57
|
+
* quietly would hide both — in the author's tests as much as in production.
|
|
58
|
+
*/
|
|
59
|
+
export declare function assertNoInjectedKeys(c: Core, obj: Record<string, unknown> | undefined, where: string): void;
|
|
60
|
+
/**
|
|
61
|
+
* Only declared, INDEXED fields (and the platform's indexed columns) may be
|
|
62
|
+
* filtered or sorted. An unindexed filter is a table scan at a customer's
|
|
63
|
+
* scale; the refusal names the index to add. The generated types carry the
|
|
64
|
+
* same list, so an author meets this in the editor first.
|
|
65
|
+
*/
|
|
66
|
+
export declare function assertFilterable(c: Core, keys: string[], where: string): void;
|
|
67
|
+
/** The filter operators a `where` value may use — the same list both evaluators implement. */
|
|
68
|
+
export declare function assertFilterShape(c: Core, where: Record<string, unknown> | undefined): void;
|
|
69
|
+
export type OrderBy = Record<string, "asc" | "desc"> | Record<string, "asc" | "desc">[];
|
|
70
|
+
export declare function orderTerms(c: Core, orderBy: OrderBy | undefined): Record<string, "asc" | "desc">[];
|
|
71
|
+
export declare function pageWindow(c: Core, args: {
|
|
72
|
+
take?: number;
|
|
73
|
+
skip?: number;
|
|
74
|
+
} | undefined): {
|
|
75
|
+
take: number;
|
|
76
|
+
skip: number;
|
|
77
|
+
};
|
|
78
|
+
/** Seeing soft-deleted rows is the owner's (or the app's own task's). */
|
|
79
|
+
export declare function assertIncludeDeleted(c: Core, flag: boolean | undefined): void;
|
|
80
|
+
export declare function readPlan(c: Core): ReadPlan;
|
|
81
|
+
/**
|
|
82
|
+
* The owner filter a READ must apply, if any: the `my-rows` reach narrows to
|
|
83
|
+
* the caller's own ids regardless of rules; otherwise it is whatever the plan
|
|
84
|
+
* said (a `creator` grant, or a user-scoped model).
|
|
85
|
+
*/
|
|
86
|
+
export declare function ownerFilter(c: Core, plan: ReadPlan): string[] | null;
|
|
87
|
+
/**
|
|
88
|
+
* The scope conjunct storage must apply — the ONE definition both clients
|
|
89
|
+
* use. Memory evaluates it as a predicate (`scopeMatches`); Prisma builds a
|
|
90
|
+
* `where` from it (`scopeWhere`). They are two views of this table:
|
|
91
|
+
*
|
|
92
|
+
* model scope | across | conjunct
|
|
93
|
+
* user | any | (none — the owner filter does the work; rows have no nodeId)
|
|
94
|
+
* instance | none | workspaceId = ws AND nodeId = node
|
|
95
|
+
* workspace | none | workspaceId = ws
|
|
96
|
+
* any non-user | my-rows | (none — the owner filter narrows to the caller's own rows everywhere)
|
|
97
|
+
* any non-user | owned-instances | workspaceId IN ownedWorkspaceIds
|
|
98
|
+
*/
|
|
99
|
+
export type ScopeConjunct = {
|
|
100
|
+
kind: "none";
|
|
101
|
+
} | {
|
|
102
|
+
kind: "instance";
|
|
103
|
+
workspaceId: string;
|
|
104
|
+
nodeId: string;
|
|
105
|
+
} | {
|
|
106
|
+
kind: "workspace";
|
|
107
|
+
workspaceId: string;
|
|
108
|
+
} | {
|
|
109
|
+
kind: "workspaces";
|
|
110
|
+
workspaceIds: string[];
|
|
111
|
+
};
|
|
112
|
+
export declare function scopeConjunct(c: Core, model?: CompiledModel): ScopeConjunct;
|
|
113
|
+
export declare function scopeMatches(c: Core, row: Row, model?: CompiledModel): boolean;
|
|
114
|
+
/** Sealed fields are readable by the row's owner and by the app's own task. */
|
|
115
|
+
export declare function mayUnseal(c: Core, row: Row): boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Refuses, or hands back the plan — never both, never neither.
|
|
118
|
+
*
|
|
119
|
+
* Narrowing is `"code" in plan`, not `plan.ok`: the repo-wide tsc runs with
|
|
120
|
+
* `strict: false`, where `ok: true | false` widens to `boolean` and a
|
|
121
|
+
* discriminated union stops narrowing. This package must satisfy the LOWER of
|
|
122
|
+
* the two configurations it is compiled under.
|
|
123
|
+
*/
|
|
124
|
+
export declare function writeGate(c: Core, op: WriteOp): Extract<WritePlan, {
|
|
125
|
+
ok: true;
|
|
126
|
+
}>;
|
|
127
|
+
export declare function checkWritableData(c: Core, data: Row, fields: string[] | null, op: string): void;
|
|
128
|
+
export declare function ownerIdForNewRow(viewer: RuleViewer): string | null;
|
|
129
|
+
/**
|
|
130
|
+
* The declared columns of a new row: what the caller gave, else the declared
|
|
131
|
+
* default, else null for an optional — and a refusal for a required field
|
|
132
|
+
* nobody supplied. Platform columns are `stampNewRow`'s.
|
|
133
|
+
*/
|
|
134
|
+
export declare function declaredValues(c: Core, data: Row): Row;
|
|
135
|
+
/** The columns the platform owns on a new row. The app never sets these. */
|
|
136
|
+
export declare function stampNewRow(c: Core, now: Date, id: string): Row;
|
|
137
|
+
/**
|
|
138
|
+
* Declared uniqueness, among the LIVE rows of this scope. Storage answers
|
|
139
|
+
* "does one exist"; this decides what that means. (A database unique would
|
|
140
|
+
* also count soft-deleted rows and block re-creating after a delete, which is
|
|
141
|
+
* why the fragment emits a plain index for these groups — v1 limitation: a
|
|
142
|
+
* race window between check and insert, to be closed with a partial unique
|
|
143
|
+
* index in the DDL step.)
|
|
144
|
+
*/
|
|
145
|
+
export declare function assertUnique(c: Core, row: Row, exists: (group: string[], values: unknown[]) => Promise<boolean>): Promise<void>;
|
|
146
|
+
/**
|
|
147
|
+
* A relation cannot cross scope (security pass, S3). A `ref:` value names a
|
|
148
|
+
* row of THIS app; that row must exist, be live, sit inside this client's
|
|
149
|
+
* scope for its OWN model, and — if that model is user-scoped — belong to the
|
|
150
|
+
* caller (unless the caller is the app's own task). Refused as `invalid` with
|
|
151
|
+
* the field named: the id may exist elsewhere, and a message that said "not
|
|
152
|
+
* yours" would confirm it.
|
|
153
|
+
*/
|
|
154
|
+
export declare function assertRefTargets(c: Core, data: Row, lookup: (refModel: CompiledModel, id: string) => Promise<Row | null>): Promise<void>;
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE CLIENT CORE — everything a plugin database client decides, with nothing
|
|
3
|
+
* about where the rows live.
|
|
4
|
+
*
|
|
5
|
+
* Two clients read an app's tables: the in-memory one an author's unit tests
|
|
6
|
+
* run against (and the workbench box), and the Prisma one production runs
|
|
7
|
+
* against. The differential test requires them to agree on every visible set
|
|
8
|
+
* for every viewer after every operation. That is only credible if they do not
|
|
9
|
+
* each re-implement the decisions — so the decisions live here, once, and a
|
|
10
|
+
* client supplies storage: "give me the rows matching this scope", "does a row
|
|
11
|
+
* with these unique values exist", "fetch this ref target".
|
|
12
|
+
*
|
|
13
|
+
* Every function takes the same `Core` handle and REFUSES through the injected
|
|
14
|
+
* `refuse`, so a refusal raised by a test and a refusal raised by production are
|
|
15
|
+
* the same object with the same code, and the package still works with no host
|
|
16
|
+
* attached.
|
|
17
|
+
*
|
|
18
|
+
* The one thing deliberately NOT here is `where` evaluation. Memory evaluates a
|
|
19
|
+
* filter in JS; Prisma hands it to Postgres. Both validate the filter's KEYS
|
|
20
|
+
* here first, and the differential test is what proves the two evaluators
|
|
21
|
+
* agree on the values.
|
|
22
|
+
*/
|
|
23
|
+
import { INJECTED_COLUMNS, canUnseal, planRead, planWrite, } from "./compile-rules.js";
|
|
24
|
+
/** Standalone default so the package works with no host attached. */
|
|
25
|
+
export const defaultRefuse = (code, message, detail) => {
|
|
26
|
+
const err = new Error(message);
|
|
27
|
+
err.name = "PluginAccessError";
|
|
28
|
+
err.code = code;
|
|
29
|
+
err.detail = detail;
|
|
30
|
+
throw err;
|
|
31
|
+
};
|
|
32
|
+
export const DEFAULT_TAKE = 50;
|
|
33
|
+
export const MAX_TAKE = 200;
|
|
34
|
+
export const FILTER_OPS = ["in", "notIn", "not", "gt", "gte", "lt", "lte", "contains"];
|
|
35
|
+
/* ───────────────────────── argument validation ────────────────────────── */
|
|
36
|
+
export function bad(c, detail) {
|
|
37
|
+
return c.refuse("invalid", "invalid", detail);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A caller-supplied scope key is REFUSED, never overridden. An app that sets
|
|
41
|
+
* its own `nodeId` is either wrong or trying something, and overriding it
|
|
42
|
+
* quietly would hide both — in the author's tests as much as in production.
|
|
43
|
+
*/
|
|
44
|
+
export function assertNoInjectedKeys(c, obj, where) {
|
|
45
|
+
if (!obj)
|
|
46
|
+
return;
|
|
47
|
+
for (const key of Object.keys(obj)) {
|
|
48
|
+
if (INJECTED_COLUMNS.includes(key)) {
|
|
49
|
+
bad(c, `${c.model.name}.${where} may not name "${key}" — the platform stamps and filters it. ` +
|
|
50
|
+
`An app that sets its own scope is either wrong or trying something; overriding it quietly would hide both.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Only declared, INDEXED fields (and the platform's indexed columns) may be
|
|
56
|
+
* filtered or sorted. An unindexed filter is a table scan at a customer's
|
|
57
|
+
* scale; the refusal names the index to add. The generated types carry the
|
|
58
|
+
* same list, so an author meets this in the editor first.
|
|
59
|
+
*/
|
|
60
|
+
export function assertFilterable(c, keys, where) {
|
|
61
|
+
for (const key of keys) {
|
|
62
|
+
if (c.model.filterable.includes(key))
|
|
63
|
+
continue;
|
|
64
|
+
if (c.model.fields[key]) {
|
|
65
|
+
bad(c, `${c.model.name}.${where} names "${key}", which has no index — add it to plugin.json ` +
|
|
66
|
+
`db.${c.model.name}.indexes (e.g. [["${key}"]]). Filtering an unindexed column is a table scan.`);
|
|
67
|
+
}
|
|
68
|
+
bad(c, `${c.model.name}.${where} names "${key}", which is not a field of ${c.model.name} — ` +
|
|
69
|
+
`filterable here: ${c.model.filterable.join(", ")}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** The filter operators a `where` value may use — the same list both evaluators implement. */
|
|
73
|
+
export function assertFilterShape(c, where) {
|
|
74
|
+
if (!where)
|
|
75
|
+
return;
|
|
76
|
+
for (const [key, cond] of Object.entries(where)) {
|
|
77
|
+
if (cond !== null && typeof cond === "object" && !(cond instanceof Date) && !Array.isArray(cond)) {
|
|
78
|
+
for (const op of Object.keys(cond)) {
|
|
79
|
+
if (!FILTER_OPS.includes(op)) {
|
|
80
|
+
bad(c, `${c.model.name}.where.${key}: unknown filter "${op}" — allowed: ${FILTER_OPS.join(", ")}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export function orderTerms(c, orderBy) {
|
|
87
|
+
if (!orderBy)
|
|
88
|
+
return [];
|
|
89
|
+
const terms = Array.isArray(orderBy) ? orderBy : [orderBy];
|
|
90
|
+
for (const t of terms)
|
|
91
|
+
assertFilterable(c, Object.keys(t), "orderBy");
|
|
92
|
+
return terms;
|
|
93
|
+
}
|
|
94
|
+
export function pageWindow(c, args) {
|
|
95
|
+
const take = args?.take ?? DEFAULT_TAKE;
|
|
96
|
+
if (take > MAX_TAKE)
|
|
97
|
+
bad(c, `take must be ${MAX_TAKE} or fewer — asked for ${take}. Page with \`cursor\`.`);
|
|
98
|
+
if (take < 0)
|
|
99
|
+
bad(c, "take must not be negative");
|
|
100
|
+
const skip = args?.skip ?? 0;
|
|
101
|
+
if (skip < 0)
|
|
102
|
+
bad(c, "skip must not be negative");
|
|
103
|
+
return { take, skip };
|
|
104
|
+
}
|
|
105
|
+
/** Seeing soft-deleted rows is the owner's (or the app's own task's). */
|
|
106
|
+
export function assertIncludeDeleted(c, flag) {
|
|
107
|
+
if (flag && c.viewer.kind !== "internal" && c.viewer.role !== "owner") {
|
|
108
|
+
bad(c, `${c.model.name}: includeDeleted is the owner's`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/* ─────────────────────────────── reads ────────────────────────────────── */
|
|
112
|
+
export function readPlan(c) {
|
|
113
|
+
return planRead(c.model, c.viewer, { viaBinding: c.viaBinding });
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The owner filter a READ must apply, if any: the `my-rows` reach narrows to
|
|
117
|
+
* the caller's own ids regardless of rules; otherwise it is whatever the plan
|
|
118
|
+
* said (a `creator` grant, or a user-scoped model).
|
|
119
|
+
*/
|
|
120
|
+
export function ownerFilter(c, plan) {
|
|
121
|
+
if (c.across === "my-rows")
|
|
122
|
+
return c.viewer.viewerIds;
|
|
123
|
+
return plan.ownerIn;
|
|
124
|
+
}
|
|
125
|
+
export function scopeConjunct(c, model = c.model) {
|
|
126
|
+
if (model.scope === "user")
|
|
127
|
+
return { kind: "none" };
|
|
128
|
+
if (c.across === "my-rows")
|
|
129
|
+
return { kind: "none" };
|
|
130
|
+
if (c.across === "owned-instances")
|
|
131
|
+
return { kind: "workspaces", workspaceIds: c.ownedWorkspaceIds };
|
|
132
|
+
if (model.scope === "workspace")
|
|
133
|
+
return { kind: "workspace", workspaceId: c.scope.workspaceId };
|
|
134
|
+
return { kind: "instance", workspaceId: c.scope.workspaceId, nodeId: c.scope.nodeId };
|
|
135
|
+
}
|
|
136
|
+
export function scopeMatches(c, row, model = c.model) {
|
|
137
|
+
const s = scopeConjunct(c, model);
|
|
138
|
+
switch (s.kind) {
|
|
139
|
+
case "none":
|
|
140
|
+
return true;
|
|
141
|
+
case "instance":
|
|
142
|
+
return row.workspaceId === s.workspaceId && row.nodeId === s.nodeId;
|
|
143
|
+
case "workspace":
|
|
144
|
+
return row.workspaceId === s.workspaceId;
|
|
145
|
+
case "workspaces":
|
|
146
|
+
return s.workspaceIds.includes(String(row.workspaceId));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Sealed fields are readable by the row's owner and by the app's own task. */
|
|
150
|
+
export function mayUnseal(c, row) {
|
|
151
|
+
if (!c.model.sealed.length)
|
|
152
|
+
return true;
|
|
153
|
+
return canUnseal(c.viewer, row);
|
|
154
|
+
}
|
|
155
|
+
/* ─────────────────────────────── writes ───────────────────────────────── */
|
|
156
|
+
/**
|
|
157
|
+
* Refuses, or hands back the plan — never both, never neither.
|
|
158
|
+
*
|
|
159
|
+
* Narrowing is `"code" in plan`, not `plan.ok`: the repo-wide tsc runs with
|
|
160
|
+
* `strict: false`, where `ok: true | false` widens to `boolean` and a
|
|
161
|
+
* discriminated union stops narrowing. This package must satisfy the LOWER of
|
|
162
|
+
* the two configurations it is compiled under.
|
|
163
|
+
*/
|
|
164
|
+
export function writeGate(c, op) {
|
|
165
|
+
if (c.across)
|
|
166
|
+
bad(c, `${c.model.name}.${op}: a cross-instance client is read-only`);
|
|
167
|
+
const plan = planWrite(c.model, op, c.viewer, { viaBinding: c.viaBinding });
|
|
168
|
+
if ("code" in plan) {
|
|
169
|
+
return c.refuse(plan.code, plan.code === "login-required" ? "login-required" : "forbidden", plan.detail);
|
|
170
|
+
}
|
|
171
|
+
return plan;
|
|
172
|
+
}
|
|
173
|
+
export function checkWritableData(c, data, fields, op) {
|
|
174
|
+
assertNoInjectedKeys(c, data, `${op} data`);
|
|
175
|
+
if (data.id !== undefined)
|
|
176
|
+
bad(c, `${c.model.name}.${op}: the platform mints \`id\``);
|
|
177
|
+
for (const key of Object.keys(data)) {
|
|
178
|
+
if (!c.model.fields[key]) {
|
|
179
|
+
bad(c, `${c.model.name}.${op}: "${key}" is not a declared field — declared: ${Object.keys(c.model.fields).join(", ")}`);
|
|
180
|
+
}
|
|
181
|
+
if (fields && !fields.includes(key)) {
|
|
182
|
+
c.refuse("forbidden", "forbidden", `${c.model.name}.${op}: this caller may write ${fields.join(", ")} — not "${key}"`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
export function ownerIdForNewRow(viewer) {
|
|
187
|
+
return viewer.userId ?? viewer.viewerIds[0] ?? null;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The declared columns of a new row: what the caller gave, else the declared
|
|
191
|
+
* default, else null for an optional — and a refusal for a required field
|
|
192
|
+
* nobody supplied. Platform columns are `stampNewRow`'s.
|
|
193
|
+
*/
|
|
194
|
+
export function declaredValues(c, data) {
|
|
195
|
+
const out = {};
|
|
196
|
+
for (const [name, field] of Object.entries(c.model.fields)) {
|
|
197
|
+
if (data[name] !== undefined)
|
|
198
|
+
out[name] = data[name];
|
|
199
|
+
// A list is empty until something is put in it. Postgres and Prisma agree:
|
|
200
|
+
// a scalar list has no null and no scalar default, so "nobody said" is `[]`,
|
|
201
|
+
// never a missing-required refusal.
|
|
202
|
+
else if (field.list)
|
|
203
|
+
out[name] = [];
|
|
204
|
+
else if (field.default !== undefined)
|
|
205
|
+
out[name] = field.default;
|
|
206
|
+
else if (field.optional)
|
|
207
|
+
out[name] = null;
|
|
208
|
+
else
|
|
209
|
+
bad(c, `${c.model.name}.create: "${name}" is required`);
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
/** The columns the platform owns on a new row. The app never sets these. */
|
|
214
|
+
export function stampNewRow(c, now, id) {
|
|
215
|
+
return {
|
|
216
|
+
id,
|
|
217
|
+
workspaceId: c.scope.workspaceId,
|
|
218
|
+
nodeId: c.model.scope === "user" ? null : c.scope.nodeId,
|
|
219
|
+
ownerId: c.model.ownedByCreator || c.model.scope === "user" ? ownerIdForNewRow(c.viewer) : null,
|
|
220
|
+
createdBy: { kind: c.viewer.kind, userId: c.viewer.userId },
|
|
221
|
+
createdAt: now,
|
|
222
|
+
updatedAt: now,
|
|
223
|
+
deletedAt: null,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Declared uniqueness, among the LIVE rows of this scope. Storage answers
|
|
228
|
+
* "does one exist"; this decides what that means. (A database unique would
|
|
229
|
+
* also count soft-deleted rows and block re-creating after a delete, which is
|
|
230
|
+
* why the fragment emits a plain index for these groups — v1 limitation: a
|
|
231
|
+
* race window between check and insert, to be closed with a partial unique
|
|
232
|
+
* index in the DDL step.)
|
|
233
|
+
*/
|
|
234
|
+
export async function assertUnique(c, row, exists) {
|
|
235
|
+
for (const group of c.model.unique) {
|
|
236
|
+
const values = group.map((col) => row[col]);
|
|
237
|
+
if (values.some((v) => v == null))
|
|
238
|
+
continue;
|
|
239
|
+
if (await exists(group, values))
|
|
240
|
+
bad(c, `${c.model.name}.create: a row with this ${group.join("+")} already exists`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* A relation cannot cross scope (security pass, S3). A `ref:` value names a
|
|
245
|
+
* row of THIS app; that row must exist, be live, sit inside this client's
|
|
246
|
+
* scope for its OWN model, and — if that model is user-scoped — belong to the
|
|
247
|
+
* caller (unless the caller is the app's own task). Refused as `invalid` with
|
|
248
|
+
* the field named: the id may exist elsewhere, and a message that said "not
|
|
249
|
+
* yours" would confirm it.
|
|
250
|
+
*/
|
|
251
|
+
export async function assertRefTargets(c, data, lookup) {
|
|
252
|
+
for (const [name, field] of Object.entries(c.model.fields)) {
|
|
253
|
+
if (field.type !== "ref" || !field.ref)
|
|
254
|
+
continue;
|
|
255
|
+
const value = data[name];
|
|
256
|
+
if (value === undefined || value === null)
|
|
257
|
+
continue;
|
|
258
|
+
const target = c.rules.models[field.ref];
|
|
259
|
+
if (!target)
|
|
260
|
+
bad(c, `${c.model.name}.${name}: ref:${field.ref} names no model`);
|
|
261
|
+
const ids = field.list ? (Array.isArray(value) ? value : [value]) : [value];
|
|
262
|
+
for (const id of ids) {
|
|
263
|
+
if (typeof id !== "string" || !id)
|
|
264
|
+
bad(c, `${c.model.name}.${name}: a ref is the target row's id (a string)`);
|
|
265
|
+
const row = await lookup(target, id);
|
|
266
|
+
const ok = !!row &&
|
|
267
|
+
!row.deletedAt &&
|
|
268
|
+
scopeMatches(c, row, target) &&
|
|
269
|
+
(target.scope !== "user" || c.viewer.kind === "internal" || (!!row.ownerId && c.viewer.viewerIds.includes(String(row.ownerId))));
|
|
270
|
+
if (!ok)
|
|
271
|
+
bad(c, `${c.model.name}.${name}: no ${target.name} "${id}" in this ${target.scope === "user" ? "account" : "instance"}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|