create-substrat 0.7.3 → 0.8.1

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/index.js CHANGED
@@ -36,11 +36,11 @@ const TEMPLATE = join(HERE, 'template');
36
36
  // The runtime packages release together off one version line (the changesets `fixed`
37
37
  // group), so one constant is right for all of them. Engines do NOT share a line —
38
38
  // each versions on its own, so one pin per engine, deliberately.
39
- const SUBSTRAT = '^0.86.0';
40
- const ENGINE_WORKORDER = '^0.8.2';
41
- const ENGINE_INVOICING = '^0.9.2';
42
- const BOUNDARY_LINT = '^0.1.2';
43
- const DEV_ISSUER = '^0.1.0';
39
+ const SUBSTRAT = '^0.91.1';
40
+ const ENGINE_WORKORDER = '^0.9.2';
41
+ const ENGINE_INVOICING = '^0.9.7';
42
+ const BOUNDARY_LINT = '^0.2.0';
43
+ const DEV_ISSUER = '^0.1.5';
44
44
 
45
45
  const DOCS = 'https://substrat.net';
46
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.7.3",
3
+ "version": "0.8.1",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -71,7 +71,13 @@ Every vertical gets this whether or not it uses a single engine:
71
71
  - **Tenancy** — tenants and scopes, isolated at the database level. A scope is one
72
72
  SQLite/DO database. Cross-tenant access is not a bug you avoid; there is no API for it.
73
73
  - **Permissions** — roles, grants, entity-narrowed grants, and every decision carries a
74
- proof path (why it was allowed).
74
+ proof path (why it was allowed). **Sharing is a kernel verb, not a table you design**:
75
+ an operation narrows a permission it already holds onto one entity, and withdraws it,
76
+ with `ctx.grant(principal, perm, entityRef)` / `ctx.revoke(principal, perm, entityRef)`
77
+ — entity-required, delegating (re-checks the caller's own decision), transactional with
78
+ the operation. Neither alternative is this: a `ctx.link` edge is permanent (not
79
+ revocable at all), and org membership is revocable but coarse-grained. Never mint an
80
+ org per domain row to get a revoke.
75
81
  - **Events + audit** — every mutation emits a kernel-stamped event. Origin fields (tenant,
76
82
  scope, actor, time) are stamped by the kernel; your code cannot mislabel one.
77
83
  - **Migrations** — journaled per module, applied lazily per scope.
@@ -363,6 +369,21 @@ operations + the `ModuleRegistration`. Keep the split — the linter and tests e
363
369
  `completeWorkOrder`. One transaction, invariants intact.
364
370
  - Portal listing: iterate and `ctx.check(perm, entityRef)` **per entity** — a proof walk,
365
371
  not UI filtering.
372
+ - **An entity's history is `readTimeline(ctx, entity, input)` from `@substrat-run/kernel`** —
373
+ not a `SELECT` against `_substrat_outbox`. Reading the spine is allowed (writes to
374
+ `_substrat_*` are not); hand-writing the query is what goes wrong. It returns
375
+ `{ entries, nextCursor }` with `{ id, type, occurredAt, actor }` per entry, and it exists
376
+ to close two traps: `actor` is stored as JSON over a union — a principal is `"01J…"`
377
+ *with quotes*, so a raw `SELECT actor` is a string that resolves against no one — and
378
+ the cursor must be the event `id`, never `occurred_at`, which is identical across every
379
+ event a single operation emits. Your permission check stays your line, above the call.
380
+ `readHistory` is the same walk plus the payload, the permissions that authorized the
381
+ write — each with the grant it resolved through, `null` for a row written before that
382
+ was recorded, which is not the same fact as `[]` — and the PII classification,
383
+ `piiClass` with the `subjectId` it is keyed by, so a renderer can decide whether an
384
+ entry is safe to show before it shows it. `subjectId` is null when `piiClass` is
385
+ `none`, and the payload is `null` after that subject's erasure, which is a supported
386
+ answer to render rather than an error.
366
387
 
367
388
  ### `src/seed.ts`
368
389
 
@@ -377,7 +398,10 @@ await host.provisionScope(actor, { tenantId: tenant, scopeId: scope, jurisdictio
377
398
 
378
399
  Define roles **per tenant** from the engines' `PERM` + your keys, assign them, create seed
379
400
  entities via `stub.invoke` (**never raw SQL**), give portal principals entity-narrowed
380
- grants. Make it idempotent.
401
+ grants. Make it idempotent. Seed-time grants are the platform actor's verb; sharing a
402
+ **user** initiates at runtime is `ctx.grant` / `ctx.revoke` inside an operation (see the
403
+ `AGENTS.md` section on sharing, and the [todo demo](https://github.com/substrat-run/substrat/tree/main/demos/todo)'s
404
+ `src/module.ts` for the two calls in place).
381
405
 
382
406
  ### `test/scenario.test.ts`
383
407
 
@@ -142,6 +142,28 @@ consumers). Rules 1–5 are enforced mechanically by `boundary-lint`.
142
142
  adapter **rejects** a `ctx.link` for an undeclared edge, so a missing one fails loudly.
143
143
  This is also what lets a portal permission-walk reach the owner.
144
144
 
145
+ ## Sharing is `ctx.grant` / `ctx.revoke`, not a table
146
+
147
+ When a person shares their own record with another person — and takes them off it again —
148
+ the operation narrows a permission it already holds onto that one entity:
149
+
150
+ ```ts
151
+ await ctx.grant(principal, PERM.listContribute, listRef(listId)); // share
152
+ await ctx.revoke(principal, PERM.listContribute, listRef(listId)); // un-share
153
+ ```
154
+
155
+ Entity-required (module code can never write a scope- or tenant-wide grant), delegating
156
+ (the caller's own decision on that entity is re-checked, so an operation can never hand out
157
+ more than it holds), and transactional with the operation. Every later `ctx.check` reads
158
+ the grant, so nothing else has to remember who may touch what.
159
+
160
+ Neither alternative is this, so you can tell a real absence from this one: a `ctx.link`
161
+ edge is **not revocable at all** — it is permanent — and org membership is revocable but
162
+ coarse-grained — a whole org, not one record. Never mint an org per domain row, or a
163
+ membership table consulted by hand in every handler, to get a revoke. The two-line
164
+ reference is the [todo demo](https://github.com/substrat-run/substrat/tree/main/demos/todo)
165
+ (`src/module.ts`, `todo/share-list` and `todo/revoke-share`).
166
+
145
167
  ## The gates — run them, believe them
146
168
 
147
169
  ```sh
@@ -1,5 +1,5 @@
1
1
  import type { Context, Hono } from 'hono';
2
- import { classifyError } from '@substrat-run/vertical-host';
2
+ import { problemResponse } from '@substrat-run/vertical-host';
3
3
  import type { ScopeStub } from '@substrat-run/kernel';
4
4
 
5
5
  /**
@@ -28,21 +28,24 @@ export function mountApi(app: Hono<any, any, any>, resolveStub: ResolveStub): vo
28
28
  const body = (c: Context) => c.req.json<Record<string, unknown>>();
29
29
 
30
30
  /**
31
- * One error vocabulary, shared with the platform surface: `classifyError`
32
- * (@substrat-run/vertical-host) is the same function `mountPlatformSurface` uses, so a
33
- * permission denial is 403, a missing thing 404, a broken invariant 409, a runtime
34
- * fault 502 — identically on both hosts. "No opinion" becomes the caller's 400.
31
+ * One error vocabulary, shared with the platform surface: `problemResponse`
32
+ * (@substrat-run/vertical-host) is built on the same `classifyError` that
33
+ * `mountPlatformSurface` uses, so a permission denial is 403, a missing thing 404, a
34
+ * broken invariant 409, a runtime fault 502 — identically on both hosts. "No opinion"
35
+ * becomes the caller's 400.
36
+ *
37
+ * The body is RFC 9457 `application/problem+json`: a `code` from the closed taxonomy
38
+ * when your throw declared one (`substratError('conflict', …)`), `about:blank` when it
39
+ * did not. `{ error }` rides along for one deprecation window, so a client reading it
40
+ * keeps working while you move to `code`.
35
41
  *
36
42
  * In `worker.ts` this handler is REPLACED: Hono keeps only the last-registered
37
43
  * `onError`, and `mountPlatformSurface` installs its own. That is harmless precisely
38
- * because both are built on `classifyError` — same input, same answer. Registering it
39
- * here is what gives `server.ts`, which mounts no platform surface, the same behaviour.
44
+ * because both are built on the same vocabulary — same input, same answer. Registering
45
+ * it here is what gives `server.ts`, which mounts no platform surface, the same
46
+ * behaviour.
40
47
  */
41
- app.onError((err, c) => {
42
- const seen = classifyError(err);
43
- if (seen) return c.json({ error: seen.message }, seen.status);
44
- return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
45
- });
48
+ app.onError((err, c) => problemResponse(c, err));
46
49
 
47
50
  // -- generic invoke ---------------------------------------------------------
48
51
  // The kernel checks a permission inside EVERY operation, so a generic route is