@aotter/mantle 0.2.1-alpha.4 → 0.2.2-alpha.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.
Files changed (79) hide show
  1. package/dist/auth/routes.d.ts +3 -1
  2. package/dist/auth/routes.js +15 -2
  3. package/dist/build/compiler.d.ts +1 -0
  4. package/dist/build/compiler.js +2 -2
  5. package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
  6. package/dist/bun/assets.d.ts +3 -1
  7. package/dist/bun/assets.js +4 -1
  8. package/dist/domain/describe/chunk-GMUORMOX.js +1 -0
  9. package/dist/domain/describe/index.js +1 -1
  10. package/dist/domain/describe/public.js +1 -1
  11. package/dist/domain/generated-tools.js +24 -9
  12. package/dist/infrastructure/bundle.js +30 -1
  13. package/dist/infrastructure/inputs.js +3 -2
  14. package/dist/infrastructure/wrangler.js +4 -3
  15. package/dist/kernel/authorization.d.ts +1 -0
  16. package/dist/kernel/authorization.js +12 -5
  17. package/dist/kernel/mcp-call.d.ts +3 -3
  18. package/dist/kernel/mcp-call.js +4 -4
  19. package/dist/kernel/mcp-handler.js +4 -3
  20. package/dist/kernel/mcp.d.ts +12 -10
  21. package/dist/kernel/mcp.js +1 -1
  22. package/dist/kernel/media-fetch.js +109 -0
  23. package/dist/kernel/media.d.ts +15 -2
  24. package/dist/kernel/media.js +138 -16
  25. package/dist/kernel/r2-media.d.ts +3 -2
  26. package/dist/kernel/r2-media.js +12 -8
  27. package/dist/kernel/result-meta.d.ts +10 -0
  28. package/dist/kernel/result-meta.js +1 -0
  29. package/dist/kernel/site-types.d.ts +54 -6
  30. package/dist/kernel/site.d.ts +1 -1
  31. package/dist/usecase/generate.js +2 -2
  32. package/dist/usecase/mcp-media.js +17 -5
  33. package/dist/usecase/mcp.js +6 -5
  34. package/dist/usecase/site.js +4 -2
  35. package/docs/examples/README.md +5 -1
  36. package/docs/examples/app-shell/README.md +7 -3
  37. package/docs/examples/app-shell/package.json +9 -9
  38. package/docs/examples/app-shell/src/jwks.ts +22 -0
  39. package/docs/examples/app-shell/src/service.ts +12 -1
  40. package/docs/examples/app-shell/wrangler.jsonc +1 -1
  41. package/docs/examples/club-events/README.md +10 -3
  42. package/docs/examples/club-events/manifests/club-events.yaml +42 -11
  43. package/docs/examples/club-events/smoke.mjs +31 -1
  44. package/docs/examples/club-events/src/handlers.ts +9 -8
  45. package/docs/examples/frontend-mcp-app/README.md +23 -0
  46. package/docs/examples/frontend-mcp-app/app.tsx +50 -0
  47. package/docs/examples/frontend-mcp-app/manifest.yaml +30 -0
  48. package/docs/examples/purchasing/README.md +9 -0
  49. package/docs/examples/purchasing/manifests/purchasing.yaml +18 -16
  50. package/docs/examples/purchasing/smoke.mjs +5 -0
  51. package/docs/examples/purchasing/wrangler.jsonc +1 -1
  52. package/docs/examples/recipes/wrangler.jsonc +1 -1
  53. package/docs/handbook/cloudflare/authentication.md +51 -0
  54. package/docs/handbook/cloudflare/deploy-and-operate.md +5 -0
  55. package/docs/handbook/cloudflare/media-r2.md +5 -2
  56. package/docs/handbook/cloudflare/service-entry.md +28 -7
  57. package/docs/handbook/concepts/runtime-and-adapters.md +1 -1
  58. package/docs/handbook/examples/hub.md +1 -1
  59. package/docs/handbook/guides/adapter-guide.md +1 -1
  60. package/docs/handbook/guides/mcp-apps.md +94 -18
  61. package/docs/handbook/guides/native-admin.md +30 -1
  62. package/docs/handbook/guides/performance.md +5 -1
  63. package/docs/handbook/guides/revisions.md +1 -1
  64. package/docs/handbook/guides/size-and-startup.md +3 -3
  65. package/docs/handbook/guides/sql-patterns.md +34 -1
  66. package/docs/handbook/reference/authorization.md +1 -1
  67. package/docs/handbook/reference/describe.md +5 -0
  68. package/docs/handbook/reference/features.md +1 -1
  69. package/docs/handbook/reference/schema.md +19 -0
  70. package/docs/handbook/reference/site-config.md +2 -0
  71. package/docs/handbook/reference/sql.md +1 -1
  72. package/docs/handbook/reference/surface.md +66 -10
  73. package/docs/handbook/reference/view.md +1 -2
  74. package/docs/handbook/releases/index.md +131 -115
  75. package/docs/handbook/start/agents.md +2 -2
  76. package/docs/handbook/start/overview.md +1 -1
  77. package/docs/handbook/start/project-and-cli.md +1 -1
  78. package/package.json +10 -10
  79. package/dist/domain/describe/chunk-CW7X5Q5T.js +0 -1
@@ -9,6 +9,8 @@ take the pattern into your own manifests and `src/`. Do not copy an example dire
9
9
  | [App shell](./app-shell/README.md) | Better Auth (email OTP, `admin` + `user`), one Worker fetch, Admin, MCP, REST, local acceptance kit. No business manifests. |
10
10
  | [Recipes](./recipes/README.md) | Publishing (draft → publish), translations, visitor pages with SEO, JSON-LD, `llms.txt`, Markdown and hreflang alternates, R2 media. |
11
11
  | [Club events](./club-events/README.md) | Members who see and change only their own rows, no duplicates, capacity that never oversells, a handler that checks before writing, a member MCP tool, members-only sign-up. |
12
+ | [Posts and revisions](../handbook/guides/revisions.md) | A focused recipe for live editing without downtime: editable revisions, frozen preview, atomic selection, immutable history and rollback. |
13
+ | [Frontend MCP App](./frontend-mcp-app/README.md) | An application-owned interactive MCP App with its own frontend entry and authorized tools. |
12
14
  | [Purchasing](./purchasing/README.md) | An internal workflow: guarded status transitions, admin-only approval (Admin and admin MCP), optimistic concurrency, several writes in one transaction, an external API with a signed callback, email and cron. |
13
15
  | [Generated website](./generated-web/README.md) | The smallest public site: list and detail pages, REST, `llms.txt`, sitemap, Markdown alternates. |
14
16
 
@@ -28,6 +30,8 @@ take the pattern into your own manifests and `src/`. Do not copy an example dire
28
30
  | A record with its children in one read | club-events' `event-roster`: a JSON array per row ("A row with its children in one read") |
29
31
  | The latest row per group, rankings | `row_number() OVER (PARTITION BY ...)` ("The latest row of each group") |
30
32
  | A third-party API that answers later (webhooks, callbacks) | purchasing's `src/routes.ts`: verify the raw request, then call a Procedure |
33
+ | Edit published content without downtime, preview the exact revision and roll back | [Posts and revisions](../handbook/guides/revisions.md): a stable publishing parent, operational revisions, a same-row seal and atomic pointer switch |
34
+ | An interactive frontend inside an MCP host | [Frontend MCP App](./frontend-mcp-app/README.md): an app-owned renderer and authorized tools |
31
35
  | Public, multilingual, search- and AI-friendly pages | recipes' `src/routes.ts` |
32
36
  | Invite-only or members-only sign-up | club-events' `members` allowlist + `src/signup.ts` |
33
37
  | Notifications and reminders | purchasing's `EmailSender` call and `src/schedule.ts` |
@@ -39,5 +43,5 @@ shell's allow-all `src/signup.ts`, unless sign-up should be invite-only. Purchas
39
43
 
40
44
  Every example uses two roles: `admin` (the admin layer) and `user`.
41
45
 
42
- [Reference service](https://github.com/aotter/mantle/tree/v0.2.1-alpha.4/docs/examples/reference-service) is the release smoke test for packed packages, not a starting point. Its source lives in the tagged repository; it is not included in the npm docs.
46
+ [Reference service](https://github.com/aotter/mantle/tree/v0.2.2-alpha.1/docs/examples/reference-service) is the release smoke test for packed packages, not a starting point. Its source lives in the tagged repository; it is not included in the npm docs.
43
47
  Projects on 0.1.x move with [the upgrade guide](../handbook/releases/upgrade-0.1-to-0.2.md).
@@ -16,8 +16,8 @@ pnpm types && pnpm check # each builds once (see Which steps build)
16
16
  cp .dev.vars.example .dev.vars
17
17
  pnpm db:init # optional seed.sql for static reference data, then pnpm db:seed (see Seed data)
18
18
  pnpm dev # builds, rebuilds on change; one-time codes print to this log
19
- pnpm smoke # builds; your smoke.mjs on smoke-kit.mjs (see Files you edit)
20
- pnpm accept # builds; hands each role to a fresh evaluator (see Independent acceptance)
19
+ pnpm smoke # builds; runs smoke.mjs, which you write on smoke-kit.mjs (init does not create it)
20
+ pnpm accept # builds; prints a signed-in URL per role for fresh evaluators (see Independent acceptance)
21
21
  pnpm test # Admin MCP App check of the last build: needs Chrome (npx playwright-core install chromium, or MANTLE_TEST_CHROMIUM=<executable>)
22
22
  ```
23
23
 
@@ -73,11 +73,15 @@ on one version.
73
73
  | `src/signup.ts` | `allowSignUp(env, email)`: who may create an account (allow-all by default). `auth.ts` calls it before Better Auth creates a user; `ADMIN_EMAIL` is always allowed. |
74
74
  | `src/auth.ts` | Better Auth options. Two roles only: `admin` (the admin layer) and `user`. Replace the console OTP sender before deploying. |
75
75
  | `smoke.mjs` | Your acceptance steps on `smoke-kit.mjs` (fresh local D1, OTP sign-in per user, REST/MCP/Admin calls). |
76
+ | `public/` | Optional static files. `mantle bundle` copies them into `dist/client`: `public/assets/*` is served at `/assets/*` by the edge without running the Worker; other files (for example `public/favicon.ico`) go through the Worker, which serves them from `ASSETS`. `public/admin` is reserved. Cloudflare's default is `Cache-Control: max-age=0, must-revalidate` with an ETag; for content-hashed files add a `public/_headers` rule with `immutable`, and keep the `/admin/assets/*` rule the build prints. `wrangler dev` rebuilds on changes under `src` and `generated` only: after editing `public/`, save a file in `src` or restart it. |
76
77
  | `accept.mjs` | Independent acceptance setup: one URL per role that carries its session, plus a minimal MCP client. Add a fixtures module for realistic data. |
77
78
 
78
79
  `src/service.ts` and `src/index.ts` rarely change. The default shell mounts REST and MCP together. If the app deliberately
79
80
  chooses asymmetric exposure, delete the blocks marked `MCP / OAuth (deliberate exception)` in `auth.ts` and `service.ts`;
80
- HTTP-only Procedures will keep generating with a warning.
81
+ HTTP-only Procedures will keep generating with a warning. `src/jwks.ts` (installed by `service.ts`) is a temporary
82
+ Workers workaround: it answers the app's own JWKS URL in process when access tokens are verified; delete it with the
83
+ MCP / OAuth blocks, and once Better Auth can take an in-process key source
84
+ ([Verifying access tokens on Workers](../../handbook/cloudflare/authentication.md#verifying-access-tokens-on-workers)).
81
85
 
82
86
  Search for `AGENT:` to find what an app must fill in.
83
87
 
@@ -15,17 +15,17 @@
15
15
  "accept": "node accept.mjs"
16
16
  },
17
17
  "dependencies": {
18
- "@aotter/mantle": "0.2.1-alpha.4",
19
- "@aotter/mantle-ui": "0.2.1-alpha.4",
20
- "@better-auth/cimd": "1.7.7",
21
- "@better-auth/core": "1.7.7",
22
- "@better-auth/mcp": "1.7.7",
23
- "@better-auth/oauth-provider": "1.7.7",
24
- "@better-auth/utils": "0.4.2",
18
+ "@aotter/mantle": "0.2.2-alpha.1",
19
+ "@aotter/mantle-ui": "0.2.2-alpha.1",
20
+ "@better-auth/cimd": "1.7.12",
21
+ "@better-auth/core": "1.7.12",
22
+ "@better-auth/mcp": "1.7.12",
23
+ "@better-auth/oauth-provider": "1.7.12",
24
+ "@better-auth/utils": "0.5.0",
25
25
  "@modelcontextprotocol/ext-apps": "2.0.0",
26
26
  "@modelcontextprotocol/server": "2.3.1",
27
- "better-auth": "1.7.7",
28
- "@aotter/mantle-admin": "0.2.1-alpha.4",
27
+ "better-auth": "1.7.12",
28
+ "@aotter/mantle-admin": "0.2.2-alpha.1",
29
29
  "react": "19.3.0",
30
30
  "react-dom": "19.3.0"
31
31
  },
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Answers this Worker's own JWKS URL in process (aotter/mantle#1932). `resolveCaller` verifies OAuth access tokens
3
+ * with Better Auth's resource client, which fetches `<baseURL>/jwks` by URL: on Workers that is a public-network
4
+ * round trip back into this same Worker (which then reads D1) before most MCP calls, because Better Auth's key cache
5
+ * lives per isolate. Only a GET of exactly that URL is answered here, from `auth.api.getJwks()` (public keys only,
6
+ * the jwt plugin's own rotation and grace rules); Better Auth still verifies the token and its DPoP binding and still
7
+ * caches the keys by that URL, and Mantle still checks the grant. Any other request, or a URL that does not match
8
+ * exactly, uses the real `fetch`. The call skips Better Auth's HTTP router, so it shares no rate-limit bucket and needs
9
+ * no ExecutionContext. A failed key read fails verification as a failed network fetch did.
10
+ * Delete this module once Better Auth's resource client accepts an in-process key source (better-auth/better-auth#11604).
11
+ */
12
+ export function withInProcessJwks(outer: typeof fetch, url: string, keys: () => Promise<unknown>): typeof fetch {
13
+ const inProcess = (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
14
+ const request = input instanceof Request ? input : undefined;
15
+ const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
16
+ return method === "GET" && (request ? request.url : String(input)) === url
17
+ ? keys().then(body => Response.json(body))
18
+ : outer(input, init);
19
+ };
20
+ // keeps whatever else the platform's fetch carries (a runtime may add properties to it)
21
+ return Object.assign(inProcess, outer);
22
+ }
@@ -17,6 +17,7 @@ import { mcpAppCatalogues } from "../generated/admin-descriptors.js";
17
17
  import type { Handlers } from "../generated/generated.js";
18
18
  import { handlers } from "./handlers.js";
19
19
  import { appRoutes } from "./routes.js";
20
+ import { withInProcessJwks } from "./jwks.js";
20
21
 
21
22
  let auth: ReturnType<typeof authLoader<ReturnType<typeof import("./auth.js").createAuth>>> | undefined;
22
23
  const authorities = new WeakMap<Request, () => Promise<Caller>>();
@@ -26,7 +27,12 @@ let serveMcpHandler: ReturnType<typeof serveMcp<Env, Handle, Handlers>> | undefi
26
27
  const serveRest = rest(options);
27
28
 
28
29
  export async function fetchService(request: Request, env: Env, context: ExecutionContext): Promise<Response> {
29
- auth ??= authLoader(() => import("./auth.js").then(m => m.createAuth(env)));
30
+ if (!auth) {
31
+ auth = authLoader(() => import("./auth.js").then(m => m.createAuth(env)));
32
+ // Once per isolate: Better Auth's JWKS fetch for verifying access tokens stays in process (see src/jwks.ts).
33
+ const loader = auth;
34
+ globalThis.fetch = withInProcessJwks(globalThis.fetch, `${env.PUBLIC_ORIGIN}/api/auth/jwks`, async () => (await loader.get()).api.getJwks());
35
+ }
30
36
  const headers = new Headers();
31
37
  let authority: Promise<Caller> | undefined;
32
38
  // One Caller per request, resolved on first use; every surface below shares it.
@@ -66,6 +72,11 @@ export async function fetchService(request: Request, env: Env, context: Executio
66
72
  if (response) return respond(response);
67
73
  const shell = await adminShell(request, path => env.ASSETS.fetch(new Request(new URL("/admin/" + path, url), request)));
68
74
  if (shell) return shell;
75
+ // public/ files outside /assets/* (for example /favicon.ico) reach the Worker first; serve them from ASSETS.
76
+ if (request.method === "GET" || request.method === "HEAD") {
77
+ const file = await env.ASSETS.fetch(request);
78
+ if (file.status !== 404) return file;
79
+ }
69
80
  return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
70
81
  } catch (error) { return surfaceFailure(error); }
71
82
  }
@@ -34,7 +34,7 @@
34
34
  "assets": {
35
35
  "directory": "dist/client",
36
36
  "binding": "ASSETS",
37
- "run_worker_first": true,
37
+ "run_worker_first": ["/*", "!/admin/assets/*", "!/assets/*"],
38
38
  "html_handling": "none"
39
39
  },
40
40
  "triggers": {
@@ -6,8 +6,8 @@ over MCP. Only members the officers listed in Admin can sign up. Officers see ev
6
6
 
7
7
  ## Capabilities shown
8
8
 
9
- - **Member-owned rows**: `restrict: { registrations: "author_id = auth.uid()" }` on the `my-registrations` View and the
10
- `cancel-registration` Procedure (`manifests/club-events.yaml`). Registrations have no field `scope`, which would hide
9
+ - **Member-owned rows**: `restrict: { registrations: "author_id = auth.uid()" }` on the `my-registrations` View
10
+ (`manifests/club-events.yaml`); `cancel-registration` pins the caller's row inline instead (see the waiting list). Registrations have no field `scope`, which would hide
11
11
  rows from officers too; Admin lists all of them.
12
12
  - **No duplicates**: `uniqueIndexes: [[eventId, member]]`. Native `authorId` is not allowed in a unique index, so the
13
13
  `register` handler writes `member` from the verified caller (`src/handlers.ts`); a second registration is 409.
@@ -26,6 +26,11 @@ over MCP. Only members the officers listed in Admin can sign up. Officers see ev
26
26
  - **An event with its attendees in one read**: the admin View `event-roster` returns each event with an `attendees`
27
27
  JSON array built by `json_group_array(json_set('{}', ...))` in a correlated subquery, so an event nobody joined has
28
28
  `[]`. PostgreSQL writes it with `jsonb_agg(jsonb_build_object(...))`; see `mantle help sql-patterns`.
29
+ - **A waiting list**: `register` with `waitlist: true` on a full event writes the member's own row with
30
+ `waitlisted: true` (`waitlist-write`), which takes no seat. `cancel-registration` has no `restrict`, because
31
+ promoting the earliest waiter changes another member's row; each statement pins the caller's row with
32
+ `author_id = auth.uid()` inline, and the final own-row DELETE makes another member's id 409 and rolls back the rest.
33
+ See `mantle help sql-patterns` ("Change another member's row").
29
34
  - **Member MCP tool**: the `register-mcp` Trigger (`surface: public`) exposes `register` on `/mcp`, so the member's AI
30
35
  registers with the member's own sign-in.
31
36
 
@@ -60,6 +65,8 @@ its sign-in is 403 and creates no user row. The officer adds the members in Admi
60
65
  they sign in. An officer creates events in Admin. A member registers (seats 3 → 2); registering twice is 409
61
66
  and takes no seat; a closed event is 409 and a missing one 404. Another member cannot see or cancel the registration
62
67
  (cancel is 409; the row and seats stay). Four members race for a 1-seat event: exactly one succeeds, the rest are 409,
63
- no seat is left and only one row exists. Cancelling returns the seat. A member registers through the MCP tool.
68
+ no seat is left and only one row exists. Cancelling returns the seat. A member registers through the MCP tool. On a full event, registering is 409 unless the
69
+ member asks for the waiting list; cancelling a seat promotes the earliest waiter (another member's cancel is still 409
70
+ and promotes nobody), leaving the waiting list frees nothing, and with nobody waiting the seat returns.
64
71
  The officer sees every registration, with the member's name, through Admin's API; a member gets 403 on Admin.
65
72
  Admin cannot write `seats_taken` or lower `capacity` below the seats taken.
@@ -37,10 +37,12 @@ spec:
37
37
  schema:
38
38
  type: object
39
39
  readOnly: true
40
- required: [eventId, member]
40
+ required: [eventId, member, waitlisted]
41
41
  properties:
42
42
  eventId: { type: string, x-mantle-ref: events }
43
43
  member: { type: string }
44
+ # true: on the waiting list, holding no seat. Cancelling a seat promotes the earliest waiter.
45
+ waitlisted: { type: boolean, title: Waiting }
44
46
  # A snapshot of the member's name at sign-up, so officers see who registered (member is an opaque id).
45
47
  memberName: { type: string, maxLength: 200 }
46
48
  ---
@@ -74,7 +76,7 @@ spec:
74
76
  sql: |
75
77
  SELECT id, title, startsAt AS "startsAt", capacity - seats_taken AS seats_left, closed FROM events ORDER BY startsAt, id
76
78
  ---
77
- # Only the caller's own rows: restrict narrows registrations to author_id = the caller.
79
+ # Only the caller's own rows, seats and waiting places: restrict narrows registrations to author_id = the caller.
78
80
  apiVersion: cms.mantle.aotter.net/v2
79
81
  kind: View
80
82
  metadata: { name: my-registrations }
@@ -84,7 +86,7 @@ spec:
84
86
  requires: { auth: { all: [ctx.user] } }
85
87
  restrict: { registrations: "author_id = auth.uid()" }
86
88
  sql: |
87
- SELECT r.id, r.eventId AS "eventId", e.title, e.startsAt AS "startsAt"
89
+ SELECT r.id, r.eventId AS "eventId", e.title, e.startsAt AS "startsAt", r.waitlisted
88
90
  FROM registrations r JOIN events e ON e.id = r.eventId
89
91
  ORDER BY r.created_at DESC, r.id DESC
90
92
  ---
@@ -100,7 +102,7 @@ spec:
100
102
  requires: { auth: { all: [ctx.admin] } }
101
103
  sql: |
102
104
  SELECT e.id, e.title, e.startsAt AS "startsAt", e.seats_taken AS "seatsTaken",
103
- (SELECT json_group_array(json_set('{}', '$.memberName', r.memberName)) FROM registrations r WHERE r.eventId = e.id) AS attendees
105
+ (SELECT json_group_array(json_set('{}', '$.memberName', r.memberName)) FROM registrations r WHERE r.eventId = e.id AND NOT r.waitlisted) AS attendees
104
106
  FROM events e ORDER BY e.startsAt, e.id
105
107
  ---
106
108
  # The member-facing entry: a ref handler checks the event, then runs register-write.
@@ -109,7 +111,7 @@ kind: Procedure
109
111
  metadata: { name: register }
110
112
  spec:
111
113
  title: Register for an event
112
- description: Take one seat at a club event for the signed-in member. Fails when the event is closed, full or already registered.
114
+ description: Take one seat at a club event for the signed-in member, or with waitlist true a place on its waiting list when it is full. Fails when the event is closed, full (without waitlist) or already registered.
113
115
  requires: { auth: { all: [ctx.user] } }
114
116
  input:
115
117
  type: object
@@ -117,6 +119,7 @@ spec:
117
119
  additionalProperties: false
118
120
  properties:
119
121
  eventId: { type: string }
122
+ waitlist: { type: boolean, description: "When the event is full, join its waiting list instead of failing." }
120
123
  output: { type: object }
121
124
  handler: { ref: register }
122
125
  ---
@@ -136,18 +139,38 @@ spec:
136
139
  output: { type: object }
137
140
  handler:
138
141
  sql: |
139
- INSERT INTO registrations (eventId, member, memberName) VALUES (input.eventId, input.member, input.memberName) RETURNING id, eventId AS "eventId";
142
+ INSERT INTO registrations (eventId, member, memberName, waitlisted) VALUES (input.eventId, input.member, input.memberName, false) RETURNING id, eventId AS "eventId";
140
143
  UPDATE events SET seats_taken = seats_taken + 1 WHERE id = input.eventId RETURNING capacity - seats_taken AS seats_left
141
144
  ---
142
- # restrict: another member's registration is invisible here, so the UPDATE matches no row -> CONFLICT, nothing changes.
145
+ # Internal: a place on the waiting list takes no seat. The member's own insert, so author_id is the member.
146
+ apiVersion: cms.mantle.aotter.net/v2
147
+ kind: Procedure
148
+ metadata: { name: waitlist-write }
149
+ spec:
150
+ requires: { auth: { all: [ctx.user] } }
151
+ input:
152
+ type: object
153
+ required: [eventId, member]
154
+ properties:
155
+ eventId: { type: string }
156
+ member: { type: string }
157
+ memberName: { type: string, maxLength: 200 }
158
+ output: { type: object }
159
+ handler:
160
+ sql: |
161
+ INSERT INTO registrations (eventId, member, memberName, waitlisted) VALUES (input.eventId, input.member, input.memberName, true) RETURNING id, eventId AS "eventId", waitlisted
162
+ ---
163
+ # No restrict: promoting the next waiter must see and change another member's row. Every statement instead pins the
164
+ # caller's own row inline (author_id = auth.uid()). The DELETE must remove exactly one row, so another member's id is
165
+ # CONFLICT and the whole program rolls back. The seat goes back only when nobody waits; otherwise it passes to the
166
+ # earliest waiter. Both are decided before the promotion changes the waiting list. D1 runs the program serialized.
143
167
  apiVersion: cms.mantle.aotter.net/v2
144
168
  kind: Procedure
145
169
  metadata: { name: cancel-registration }
146
170
  spec:
147
171
  title: Cancel a registration
148
- description: Cancel one of the signed-in member's registrations and return the seat.
172
+ description: Cancel one of the signed-in member's registrations or waiting places. A freed seat goes to the first member on the waiting list, or back to the event.
149
173
  requires: { auth: { all: [ctx.user] } }
150
- restrict: { registrations: "author_id = auth.uid()" }
151
174
  input:
152
175
  type: object
153
176
  required: [id]
@@ -157,8 +180,16 @@ spec:
157
180
  output: { type: object }
158
181
  handler:
159
182
  sql: |
160
- UPDATE events SET seats_taken = seats_taken - 1 WHERE id = (SELECT eventId FROM registrations WHERE id = input.id) RETURNING capacity - seats_taken AS seats_left;
161
- DELETE FROM registrations WHERE id = input.id RETURNING id
183
+ UPDATE events SET seats_taken = seats_taken - 1
184
+ WHERE id = (SELECT r.eventId FROM registrations r WHERE r.id = input.id AND r.author_id = auth.uid() AND NOT r.waitlisted)
185
+ AND NOT EXISTS (SELECT 1 FROM registrations w WHERE w.eventId = events.id AND w.waitlisted)
186
+ RETURNING capacity - seats_taken AS seats_left;
187
+ UPDATE registrations SET waitlisted = false
188
+ WHERE id = (SELECT w.id FROM registrations w JOIN registrations r ON r.eventId = w.eventId
189
+ WHERE r.id = input.id AND r.author_id = auth.uid() AND NOT r.waitlisted AND w.waitlisted
190
+ ORDER BY w.created_at, w.id LIMIT 1)
191
+ RETURNING id AS promoted;
192
+ DELETE FROM registrations WHERE id = input.id AND author_id = auth.uid() RETURNING id
162
193
  ---
163
194
  apiVersion: cms.mantle.aotter.net/v2
164
195
  kind: Trigger
@@ -76,7 +76,7 @@ await smoke(async ({ call, mcp, signIn, sql, owner, step, logs }) => {
76
76
  assert.equal(foreign.status, 409, JSON.stringify(foreign.body));
77
77
  assert.equal((await sql(`SELECT count(*) AS n FROM registrations WHERE id = '${aliceHike.id}'`))[0].n, 1);
78
78
  assert.equal(await seatsOf(hike.id), 2);
79
- step("another member neither sees nor cancels it: cancel is 409, the row and seats_left are unchanged (restrict)");
79
+ step("another member neither sees nor cancels it: cancel is 409, the row and seats_left are unchanged (author_id = auth.uid())");
80
80
 
81
81
  const racers = [];
82
82
  for (const name of ["carol", "dave", "erin", "frank"]) racers.push(await signIn(`${name}@example.test`));
@@ -124,4 +124,34 @@ await smoke(async ({ call, mcp, signIn, sql, owner, step, logs }) => {
124
124
  assert.equal(shrunk.status, 409, JSON.stringify(shrunk.body));
125
125
  assert.equal(await seatsOf(hike.id), before);
126
126
  step("officers edit an event with the whole record; the server-maintained seats_taken is ignored, and capacity below the seats taken is 409");
127
+
128
+ await create("Talk", 1);
129
+ const { Talk: talk } = await events(alice);
130
+ const carol = racers[0], register = (cookie, body) => call("POST", "/api/registrations", { cookie, body: { eventId: talk.id, ...body } });
131
+ const cancel = (cookie, id) => call("POST", "/api/registrations/cancel", { cookie, body: { id } });
132
+ const place = async cookie => (await mine(cookie)).find(r => r.eventId === talk.id);
133
+ assert.equal((await register(alice)).status, 200);
134
+ assert.equal((await register(bob)).status, 409);
135
+ for (const cookie of [bob, carol]) assert.equal((await register(cookie, { waitlist: true })).status, 200);
136
+ assert.equal(await seatsOf(talk.id), 0);
137
+ assert.equal(!!(await place(bob)).waitlisted, true);
138
+ step("a full event is 409, unless the member asks for the waiting list: then a place that takes no seat");
139
+
140
+ const seat = await place(alice), bobPlace = await place(bob), carolPlace = await place(carol);
141
+ assert.equal((await cancel(carol, seat.id)).status, 409);
142
+ assert.equal(!!(await place(bob)).waitlisted, true);
143
+ assert.equal((await place(alice)).id, seat.id);
144
+ step("another member cancelling the seat is still 409 and nobody is promoted: the whole program rolled back");
145
+
146
+ assert.equal((await cancel(alice, seat.id)).status, 200);
147
+ assert.equal(!!(await place(bob)).waitlisted, false);
148
+ assert.equal(!!(await place(carol)).waitlisted, true);
149
+ assert.equal(await seatsOf(talk.id), 0);
150
+ step("cancelling a seat promotes the earliest waiter; the seat passes on and seats_left stays 0");
151
+
152
+ assert.equal((await cancel(carol, carolPlace.id)).status, 200);
153
+ assert.equal(await seatsOf(talk.id), 0);
154
+ assert.equal((await cancel(bob, bobPlace.id)).status, 200);
155
+ assert.equal(await seatsOf(talk.id), 1);
156
+ step("leaving the waiting list frees no seat; cancelling with nobody waiting returns the seat");
127
157
  });
@@ -3,23 +3,24 @@ import { userOf } from "@aotter/mantle/auth";
3
3
  import type { Handlers } from "../generated/generated.js";
4
4
 
5
5
  export const handlers: Handlers = {
6
- register: async ({ caller, store }, { eventId }) => {
6
+ register: async ({ caller, store }, { eventId, waitlist }) => {
7
7
  if (caller.kind !== "user") throw new ContractError("AUTH_DENIED", "Sign in as a member to register.", "procedures/register");
8
8
  // Check before writing: a clear refusal for a missing or closed event. Seats are NOT checked here;
9
9
  // the `seats_taken <= capacity` check in register-write is what holds under concurrent requests.
10
10
  const event = await store.collection("events").findById(eventId);
11
11
  if (!event) throw new ContractError("NOT_FOUND", "No such event.", "procedures/register");
12
12
  if (event.closed) throw new ContractError("CONFLICT", "Registration for this event is closed.", "procedures/register");
13
+ // `member` comes from the verified caller, never from request input; uniqueIndexes [eventId, member] refuses a second seat.
14
+ // userOf: the Better Auth user record the caller was resolved from (any credential), so officers see who registered.
15
+ const user = userOf(caller), row = { eventId, member: caller.subject, memberName: user?.name || user?.email || caller.subject };
13
16
  try {
14
- // `member` comes from the verified caller, never from request input; uniqueIndexes [eventId, member] refuses a second seat.
15
- // userOf: the Better Auth user record the caller was resolved from (any credential), so officers see who registered.
16
- const user = userOf(caller);
17
- return await store.procedures["register-write"]({ eventId, member: caller.subject, memberName: user?.name || user?.email || caller.subject });
17
+ return await store.procedures["register-write"](row);
18
18
  } catch (error) {
19
19
  // register-write's only Schema check is the capacity one.
20
- if (error instanceof ContractError && error.code === "CONFLICT" && error.fields?.kind === "check" && error.fields.schema === "events")
21
- throw new ContractError("CONFLICT", "The event is full.", "procedures/register");
22
- throw error;
20
+ if (!(error instanceof ContractError && error.code === "CONFLICT" && error.fields?.kind === "check" && error.fields.schema === "events")) throw error;
21
+ if (!waitlist) throw new ContractError("CONFLICT", "The event is full.", "procedures/register");
22
+ // ponytail: a seat freed between the two calls stays unused until the next cancel promotes a waiter.
23
+ return await store.procedures["waitlist-write"](row);
23
24
  }
24
25
  },
25
26
  };
@@ -0,0 +1,23 @@
1
+ # Application-owned frontend MCP App
2
+
3
+ This small React entry uses the official ext-apps `useApp` and optional `mantle-ui` presentation. It imports no `mantle-admin`, Provider, Admin hooks or Admin API. The application owns its build and domain page. Keep React and React DOM pinned to one exact version; install the version-matched Core, optional UI, official ext-apps and its peers. Bundle `app.tsx` into a single-file HTML document containing `<div id="root"></div>` with your browser build tool.
4
+
5
+ Generate the accompanying manifest with `mantle generate <target> generated manifest.yaml`. Register the built HTML through the existing Core contributor, on the frontend endpoint:
6
+
7
+ ```ts
8
+ import { mcp } from "./generated/mcp.js";
9
+ const handle = mcp({
10
+ db: env => env.DB,
11
+ caller: request => verifiedCaller(request),
12
+ apps: { public: { resources: [{
13
+ uri: "ui://my-tasks/build-1", name: "my-tasks", renders: ["my_tasks"],
14
+ html: builtHtml,
15
+ }] } },
16
+ });
17
+ ```
18
+
19
+ `verifiedCaller` is the application's real authentication integration; never derive authority from tool arguments, page state or a user-supplied owner ID. There are no credentials in the HTML. Change the resource URI for a new UI build and update registration consistently. Core owns resource/tool registration and caller enforcement; it neither builds this UI nor imports React. Official ext-apps owns the connection. UI only renders data and invokes callbacks.
20
+
21
+ `public` names the frontend surface, **not anonymous permission**. `my-tasks` requires a verified user, and the Schema scope limits SQL rows to `auth.uid()`. Admin tools are a separate surface. UI visibility grants no access. The example's load-more calls the same declared View through the host; it does not fetch `/admin/api`. Opening it performs no write. The application reads its consumed row fields and cursor from structured output or a JSON text result; malformed results show an error, a fresh valid result restores the page, and a pending older page cannot append to a newer result. Protocol metadata remains the official client’s responsibility.
22
+
23
+ Repository acceptance: `bun test packages/mantle/tests/frontend-mcp-app.test.ts` checks generated SQL scope, anonymous/member denials, owner paging and resource linkage. `MANTLE_BROWSER_TESTS=1 MANTLE_TEST_CHROMIUM=/path/to/chrome pnpm --filter @aotter/mantle-admin exec vitest run test/frontend-mcp-app-browser.test.ts` builds this entry without Admin runtime code and renders it in the official AppBridge. The latter command reuses repository browser tooling only; Admin is not an application dependency. This is not an independently installed packed consumer or a real ChatGPT/Claude acceptance result; those remain separate deployment checks.
@@ -0,0 +1,50 @@
1
+ import { useRef, useState } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { useApp } from "@modelcontextprotocol/ext-apps/react";
4
+ import { EntityPreview } from "@aotter/mantle-ui";
5
+
6
+ interface Rows { rows: { id: string; title: string; version: number }[]; nextCursor?: string }
7
+ const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
8
+ function readRows(result: { isError?: boolean; structuredContent?: unknown; content: readonly { type: string; text?: string }[] }): Rows {
9
+ if (result.isError) throw new Error("Unable to read tasks");
10
+ const output: unknown = result.structuredContent ?? JSON.parse(result.content.find(block => block.type === "text")?.text ?? "");
11
+ if (!record(output) || !Array.isArray(output.rows) || (output.nextCursor != null && (typeof output.nextCursor !== "string" || !output.nextCursor))) throw new Error("Invalid task result");
12
+ const rows = output.rows.map((row: unknown) => {
13
+ if (!record(row) || typeof row.id !== "string" || typeof row.title !== "string" || typeof row.version !== "number" || !Number.isSafeInteger(row.version) || row.version < 1) throw new Error("Invalid task row");
14
+ return { id: row.id, title: row.title, version: row.version };
15
+ });
16
+ return { rows, ...(typeof output.nextCursor === "string" ? { nextCursor: output.nextCursor } : {}) };
17
+ }
18
+ function Frontend() {
19
+ const generation = useRef(0);
20
+ const [page, setPage] = useState<Rows>({ rows: [] });
21
+ const [failure, setFailure] = useState<string | null>(null);
22
+ const [busy, setBusy] = useState(false);
23
+ const { app, isConnected, error } = useApp({
24
+ appInfo: { name: "my-tasks", version: "1" }, capabilities: {},
25
+ onAppCreated: app => { app.ontoolresult = result => {
26
+ // This application consumes its declared View result, not Admin metadata.
27
+ generation.current++;
28
+ try { setPage(readRows(result)); setFailure(null); }
29
+ catch { setFailure("Unable to read tasks. Ask for the task list again."); }
30
+ }; },
31
+ });
32
+ if (error || failure) return <p role="alert">{error?.message ?? failure}</p>;
33
+ if (!isConnected) return <p>Connecting…</p>;
34
+ const more = async () => {
35
+ if (!app || busy || !page.nextCursor) return;
36
+ const opened = generation.current;
37
+ setBusy(true);
38
+ try {
39
+ const result = await app.callServerTool({ name: "my_tasks", arguments: { limit: 1, cursor: page.nextCursor } });
40
+ const next = readRows(result);
41
+ if (opened !== generation.current) return;
42
+ setPage(current => ({ rows: [...current.rows, ...next.rows], ...(next.nextCursor ? { nextCursor: next.nextCursor } : {}) }));
43
+ } catch { if (opened === generation.current) setFailure("Unable to load more tasks"); }
44
+ finally { setBusy(false); }
45
+ };
46
+ return <main aria-busy={busy}><h1>My tasks</h1>{page.rows.map(row => <EntityPreview key={row.id} entry={{ id: row.id, version: row.version, data: { title: row.title } }} />)}
47
+ {page.nextCursor && app?.getHostCapabilities()?.serverTools ? <button disabled={busy} onClick={() => { void more(); }}>Load more</button> : null}
48
+ </main>;
49
+ }
50
+ createRoot(document.getElementById("root")!).render(<Frontend />);
@@ -0,0 +1,30 @@
1
+ apiVersion: cms.mantle.aotter.net/v2
2
+ kind: Schema
3
+ metadata: {name: tasks}
4
+ spec:
5
+ title: Tasks
6
+ lifecycle: operational
7
+ scope: owner = auth.uid()
8
+ indexes: [[owner, id]]
9
+ schema:
10
+ type: object
11
+ required: [owner, title]
12
+ properties:
13
+ owner: {type: string}
14
+ title: {type: string}
15
+ ---
16
+ apiVersion: cms.mantle.aotter.net/v2
17
+ kind: View
18
+ metadata: {name: my-tasks}
19
+ spec:
20
+ surface: public
21
+ requires: {auth: {all: [ctx.user]}}
22
+ sql: SELECT id, title, version FROM tasks ORDER BY id
23
+ ---
24
+ apiVersion: cms.mantle.aotter.net/v2
25
+ kind: View
26
+ metadata: {name: administration}
27
+ spec:
28
+ surface: admin
29
+ requires: {auth: {all: [ctx.admin]}}
30
+ sql: SELECT id, title, version FROM tasks ORDER BY id
@@ -8,6 +8,11 @@ that have been waiting too long.
8
8
 
9
9
  ## Capabilities shown
10
10
 
11
+ - **Declared relationships**: each request stores a read-only `budgetId` reference to its budget. Each ledger row references
12
+ both its budget and its request; `uniqueIndexes: [[requestId]]` permits at most one ledger row per request. The ER diagram
13
+ shows budget-to-request and budget-to-ledger as one-to-many. Department names remain readable snapshots; spending joins
14
+ stable budget IDs. These references are model metadata, not database foreign keys. Requests and ledger remain available
15
+ as standalone Admin collections, with the ledger also grouped under its request.
11
16
  - **Status workflow with guarded transitions**: the write Procedures use
12
17
  `UPDATE requests SET requestStatus = 'approved' ... WHERE id = input.id AND requestStatus = 'submitted'`. A match on
13
18
  0 rows is a `CONFLICT` (409). See `manifests/purchasing.yaml` (`approve-request-write`, `reject-request-write`,
@@ -65,6 +70,10 @@ scheme.
65
70
 
66
71
  ## Run it
67
72
 
73
+ Existing copies need a native migration for the new `budgetId` fields on requests
74
+ and ledger: backfill them from the existing department budget and request,
75
+ then enforce NOT NULL. Regeneration alone does not migrate an occupied database.
76
+
68
77
  This runs the example by itself, to read and try. For your own app, run `mantle init` and take only the patterns you need into its manifests and `src/` (`mantle help examples`).
69
78
 
70
79
  ```sh
@@ -25,21 +25,22 @@ metadata: { name: requests }
25
25
  spec:
26
26
  title: Purchase requests
27
27
  lifecycle: operational
28
- indexes: [[authorId, createdAt, id], [requestStatus, createdAt, id]]
28
+ indexes: [[authorId, createdAt, id], [requestStatus, createdAt, id], [budgetId, createdAt, id]]
29
29
  searchableFields: [title, department]
30
30
  # Sidebar: in the Approvals group (src/admin.tsx), after the queue of requests waiting for a decision.
31
- uiSchema: { list: { primaryField: title, columns: [department, amount, requestStatus], filterField: requestStatus }, nav: { group: approvals, order: 2 } }
31
+ uiSchema: { list: { primaryField: title, columns: [department, amount, requestStatus], filterField: requestStatus }, nav: { standalone: true, group: approvals, order: 2 } }
32
32
  schema:
33
33
  type: object
34
34
  # Admin reads requests but cannot edit them generically: a generic edit could move requestStatus back to
35
35
  # 'submitted' and spend the budget twice. Every change goes through the guarded Procedures below.
36
36
  readOnly: true
37
37
  additionalProperties: false
38
- required: [title, amount, department, requestStatus]
38
+ required: [title, amount, department, budgetId, requestStatus]
39
39
  properties:
40
40
  title: { type: string, minLength: 1 }
41
41
  amount: { type: integer, minimum: 1 }
42
42
  department: { type: string }
43
+ budgetId: { type: string, x-mantle-ref: budgets, readOnly: true }
43
44
  requestStatus: { type: string, enum: [submitted, approved, rejected, ordered, confirmed] }
44
45
  supplierOrderId: { type: string }
45
46
  ---
@@ -49,18 +50,19 @@ metadata: { name: ledger }
49
50
  spec:
50
51
  title: Budget ledger
51
52
  lifecycle: operational
52
- indexes: [[createdAt, id]]
53
+ indexes: [[createdAt, id], [budgetId, createdAt, id]]
53
54
  # One row per spending request.
54
55
  uniqueIndexes: [[requestId]]
55
56
  searchableFields: [department]
56
- uiSchema: { list: { primaryField: department, columns: [amount, remainingAfter, requestId] } }
57
+ uiSchema: { list: { primaryField: department, columns: [amount, remainingAfter, requestId] }, nav: { parentField: requestId, standalone: true } }
57
58
  schema:
58
59
  type: object
59
60
  readOnly: true # written only by approve-request-write and submit-request
60
61
  additionalProperties: false
61
- required: [requestId, department, amount, remainingAfter]
62
+ required: [requestId, budgetId, department, amount, remainingAfter]
62
63
  properties:
63
- requestId: { type: string }
64
+ requestId: { type: string, x-mantle-ref: requests }
65
+ budgetId: { type: string, x-mantle-ref: budgets }
64
66
  department: { type: string }
65
67
  amount: { type: integer }
66
68
  remainingAfter: { type: integer }
@@ -140,17 +142,17 @@ spec:
140
142
  # overspend. A pending request matches nothing there, which a set form (IN (SELECT ...), INSERT ... SELECT) allows.
141
143
  handler:
142
144
  sql: |
143
- INSERT INTO requests (title, amount, department, requestStatus)
144
- SELECT input.title, input.amount, b.department, CASE WHEN input.amount <= 100 THEN 'approved' ELSE 'submitted' END
145
+ INSERT INTO requests (title, amount, department, requestStatus, budgetId)
146
+ SELECT input.title, input.amount, b.department, CASE WHEN input.amount <= 100 THEN 'approved' ELSE 'submitted' END, b.id
145
147
  FROM budgets b WHERE b.department = input.department
146
148
  RETURNING id, version, requestStatus AS "requestStatus";
147
- UPDATE budgets SET remaining = remaining - (SELECT SUM(r.amount) FROM requests r WHERE r.department = budgets.department
149
+ UPDATE budgets SET remaining = remaining - (SELECT SUM(r.amount) FROM requests r WHERE r.budgetId = budgets.id
148
150
  AND r.author_id = auth.uid() AND r.created_at = now() AND r.requestStatus = 'approved'
149
151
  AND NOT EXISTS (SELECT 1 FROM ledger l WHERE l.requestId = r.id))
150
- WHERE department IN (SELECT r.department FROM requests r WHERE r.author_id = auth.uid() AND r.created_at = now()
152
+ WHERE id IN (SELECT r.budgetId FROM requests r WHERE r.author_id = auth.uid() AND r.created_at = now()
151
153
  AND r.requestStatus = 'approved' AND NOT EXISTS (SELECT 1 FROM ledger l WHERE l.requestId = r.id));
152
- INSERT INTO ledger (requestId, department, amount, remainingAfter)
153
- SELECT r.id, r.department, r.amount, b.remaining FROM requests r JOIN budgets b ON b.department = r.department
154
+ INSERT INTO ledger (requestId, budgetId, department, amount, remainingAfter)
155
+ SELECT r.id, r.budgetId, r.department, r.amount, b.remaining FROM requests r JOIN budgets b ON b.id = r.budgetId
154
156
  WHERE r.author_id = auth.uid() AND r.created_at = now() AND r.requestStatus = 'approved'
155
157
  AND NOT EXISTS (SELECT 1 FROM ledger l WHERE l.requestId = r.id)
156
158
  ---
@@ -220,9 +222,9 @@ spec:
220
222
  WHERE id = input.id AND version = input.expectedVersion AND requestStatus = 'submitted'
221
223
  RETURNING id, title, amount, author_id AS "authorId";
222
224
  UPDATE budgets SET remaining = remaining - (SELECT r.amount FROM requests r WHERE r.id = input.id)
223
- WHERE department = (SELECT r.department FROM requests r WHERE r.id = input.id);
224
- INSERT INTO ledger (requestId, department, amount, remainingAfter)
225
- SELECT r.id, r.department, r.amount, b.remaining FROM requests r JOIN budgets b ON b.department = r.department
225
+ WHERE id = (SELECT r.budgetId FROM requests r WHERE r.id = input.id);
226
+ INSERT INTO ledger (requestId, budgetId, department, amount, remainingAfter)
227
+ SELECT r.id, r.budgetId, r.department, r.amount, b.remaining FROM requests r JOIN budgets b ON b.id = r.budgetId
226
228
  WHERE r.id = input.id
227
229
  ---
228
230
  apiVersion: cms.mantle.aotter.net/v2
@@ -185,6 +185,11 @@ try {
185
185
  assert.equal((await ledgerRows()).length, ledgerBefore);
186
186
  assert.equal((await query("SELECT count(*) AS n FROM requests"))[0].n, requestsBefore);
187
187
  step("CASE approves a request of 100 or less at submission: the status, the budget and the ledger row commit together, and one that would overspend is refused with nothing written");
188
+ const engineeringBudget = (await query("SELECT id FROM budgets WHERE department = 'Engineering'"))[0].id;
189
+ assert.equal((await query(`SELECT budgetid FROM requests WHERE id = '${laptop}'`))[0].budgetid, engineeringBudget);
190
+ assert.equal((await query(`SELECT budgetid FROM ledger WHERE requestid = '${pens.id}'`))[0].budgetid, engineeringBudget);
191
+ assert.equal((await query("SELECT COUNT(*) AS n FROM ledger l JOIN requests r ON r.id = l.requestid WHERE l.budgetid <> r.budgetid"))[0].n, 0);
192
+ step("requests and ledger rows reference the same stable budget, with at most one ledger row per request");
188
193
  }, { vars: { EMPLOYEE_DOMAIN: "example.test", SUPPLIER_API_URL: `http://127.0.0.1:${supplier.address().port}/orders`, SUPPLIER_WEBHOOK_SECRET: SECRET } });
189
194
  } finally {
190
195
  supplier.close();
@@ -39,7 +39,7 @@
39
39
  "assets": {
40
40
  "directory": "dist/client",
41
41
  "binding": "ASSETS",
42
- "run_worker_first": true,
42
+ "run_worker_first": ["/*", "!/admin/assets/*", "!/assets/*"],
43
43
  "html_handling": "none"
44
44
  },
45
45
  "triggers": {