create-substrat 0.6.3 → 0.7.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.
@@ -2,9 +2,10 @@
2
2
  * This vertical as a deployable Cloudflare Worker — SANDBOX-CLEAN and
3
3
  * control-plane-less: the shape `substrat push` deploys into the platform's
4
4
  * dispatch namespace. Its only durable stores are its OWN DO classes — `SCOPE`
5
- * (kernel + engines + this vertical, bundled) and `SWEEPER` (the deployment's
6
- * own timer, #461); no CONTROL_PLANE binding, no service bindings, no ASSETS
7
- * binding — the platform refuses those.
5
+ * (kernel + engines + this vertical, bundled), `SWEEPER` (the deployment's own
6
+ * timer, #461) and `CONFIG` (per-instance settings delivered by the platform);
7
+ * no CONTROL_PLANE binding, no service bindings, no ASSETS binding — the
8
+ * platform refuses those.
8
9
  *
9
10
  * `substrat push` derives the deploy config from `substrat.runtimeNeeds` in
10
11
  * package.json (entry = this file, stores = the DO classes exported here) —
@@ -25,8 +26,10 @@ import type { Context } from 'hono';
25
26
  import { HTTPException } from 'hono/http-exception';
26
27
  import {
27
28
  principalId,
29
+ resolveScopedEnvSpec,
28
30
  scopeId,
29
31
  tenantId,
32
+ z,
30
33
  type PrincipalId,
31
34
  type ScopeId,
32
35
  type TenantId,
@@ -41,10 +44,21 @@ import {
41
44
  import { readRoutedNode, RouterAssertionError, type ScopeStub } from '@substrat-run/kernel';
42
45
  import { mountPlatformSurface } from '@substrat-run/vertical-host';
43
46
  import { MODULES, OWNER_ROLE_KEY, ROLES } from './provision.js';
47
+ import { SHOP_ENV } from './manifest.js';
48
+ import { mountApi } from './routes.js';
49
+ import { AUTH_CONFIG_KEY, ConfigDO, type ConfigDo } from './config-do.js';
44
50
 
45
51
  /** The scope-DO class = the app binary: kernel + engines + this vertical, bundled. */
46
52
  export const ScopeDO = defineScopeDO(MODULES, {});
47
53
 
54
+ /**
55
+ * The per-instance config store (`config-do.ts`) — one per tenant, rows keyed by scope.
56
+ * Declared as a store in package.json `substrat.runtimeNeeds.stores`, like `ScopeDO`
57
+ * above and `SweeperDO` below. Re-exported because workerd resolves a DO class from the
58
+ * ENTRY module's exports; defining it in another file is fine, hiding it here is not.
59
+ */
60
+ export { ConfigDO };
61
+
48
62
  /**
49
63
  * The deployment's own timer (#461): a roster-keeping singleton whose alarm runs
50
64
  * each provisioned scope's due recurring work — executor retries and any
@@ -82,6 +96,8 @@ interface Env {
82
96
  SCOPE: DurableObjectNamespace;
83
97
  /** The roster-keeping sweep singleton — the deployment's own timer (#461). */
84
98
  SWEEPER: DurableObjectNamespace;
99
+ /** Per-instance config delivered by the platform (`/internal/configure`). */
100
+ CONFIG: DurableObjectNamespace;
85
101
  /** Local dev only: when 'true', trust the `x-principal` header. NEVER set in prod. */
86
102
  ALLOW_DEV_HEADER?: string;
87
103
  /** Shared secret the router presents (how this worker knows the asserted node is real). */
@@ -110,6 +126,49 @@ function hostFor(env: Env): CloudflareScopeHost {
110
126
  return host;
111
127
  }
112
128
 
129
+ /** This tenant's config DO — one per tenant, holding a row set per scope. */
130
+ function configDo(env: Env, node: Node): DurableObjectStub & ConfigDo {
131
+ return env.CONFIG.get(env.CONFIG.idFromName(node.tenantId)) as DurableObjectStub & ConfigDo;
132
+ }
133
+
134
+ /**
135
+ * The scope's delivered auth choice. Parsed LENIENTLY on purpose: an absent or
136
+ * malformed entry means "nothing delivered", never a throw, so a bad delivery can
137
+ * never lock an instance out of its own login.
138
+ */
139
+ const authChoice = z.object({
140
+ mode: z.literal('oidc'),
141
+ issuer: z.string().min(1),
142
+ clientId: z.string().min(1).optional(),
143
+ clientSecret: z.string().min(1).optional(),
144
+ });
145
+
146
+ /**
147
+ * Everything this instance was configured with, in ONE DO hop: the ordinary declared
148
+ * settings (`SHOP_ENV`) resolved delivered > env > default, and the structured
149
+ * `substrat:auth` choice the dashboard's Identity tab sends.
150
+ *
151
+ * Reading settings THROUGH this — rather than off `env` — is the whole reason the
152
+ * `/internal/configure` hook exists: a spec `default` rides as a worker binding shared
153
+ * by every install of one serving script, so `env.SHOP_NAME` is the same string for
154
+ * every tenant no matter what any of them saved.
155
+ */
156
+ async function instanceConfig(env: Env, node: Node) {
157
+ const delivered = await configDo(env, node).getScopeConfig(node.scopeId);
158
+ const settings = resolveScopedEnvSpec(SHOP_ENV, env as unknown as Record<string, unknown>, delivered).values;
159
+ let identity: z.infer<typeof authChoice> | null = null;
160
+ const raw = delivered[AUTH_CONFIG_KEY];
161
+ if (raw) {
162
+ try {
163
+ const parsed = authChoice.safeParse(JSON.parse(raw));
164
+ identity = parsed.success ? parsed.data : null;
165
+ } catch {
166
+ identity = null;
167
+ }
168
+ }
169
+ return { settings, identity };
170
+ }
171
+
113
172
  /**
114
173
  * THE AUTH SEAM (see the header comment): resolve the caller to a PrincipalId,
115
174
  * or null for nobody. Replace the body with a real session/bearer verifier
@@ -127,25 +186,49 @@ async function authenticatedPrincipal(req: Request, env: Env): Promise<Principal
127
186
  async function stub(c: Context<{ Bindings: Env }>): Promise<ScopeStub> {
128
187
  const node = nodeFor(c.req.raw, c.env);
129
188
  const principal = await authenticatedPrincipal(c.req.raw, c.env);
130
- if (!principal) throw new HTTPException(401, { message: 'unauthorized' });
189
+ if (!principal) throw new HTTPException(401, { message: await unauthorizedReason(c.env, node) });
131
190
  return hostFor(c.env).getScope(principal, node.tenantId, node.scopeId);
132
191
  }
133
192
 
193
+ /**
194
+ * Why the caller is nobody — a DIAGNOSIS, not a bare "unauthorized".
195
+ *
196
+ * The expensive case to debug is the one that looks like a platform bug and is not:
197
+ * a tenant picks an identity provider in the dashboard, the platform delivers it here
198
+ * successfully, and every request is still 401 — because this starter ships no auth.
199
+ * Saying so, and naming the seam, is the difference between a five-minute fix and a
200
+ * support thread. Only runs on the failure path, so it costs a DO hop on 401s alone.
201
+ */
202
+ async function unauthorizedReason(env: Env, node: Node): Promise<string> {
203
+ try {
204
+ const { identity } = await instanceConfig(env, node);
205
+ if (identity) {
206
+ return `unauthorized — this instance has an identity provider configured (${identity.issuer}), but this vertical has not wired it up yet: implement \`authenticatedPrincipal\` in src/worker.ts (the auth seam)`;
207
+ }
208
+ } catch {
209
+ // The config store is unreachable — that is not the caller's problem to hear about.
210
+ }
211
+ return 'unauthorized';
212
+ }
213
+
134
214
  const app = new Hono<{ Bindings: Env }>();
135
215
 
136
- // Who am I — resolves the caller without invoking anything.
216
+ // Who am I, and what instance am I on — resolves the caller without invoking
217
+ // anything. Auth-shaped and host-specific, so it stays OUT of the shared table:
218
+ // the dev server answers `/api/cast` instead, and a client can tell the two apart.
137
219
  app.get('/api/me', async (c) => {
220
+ const node = nodeFor(c.req.raw, c.env);
138
221
  const principal = await authenticatedPrincipal(c.req.raw, c.env);
139
- if (!principal) return c.json({ error: 'unauthorized' }, 401);
140
- return c.json({ principal });
222
+ if (!principal) return c.json({ error: await unauthorizedReason(c.env, node) }, 401);
223
+ const { settings, identity } = await instanceConfig(c.env, node);
224
+ return c.json({ principal, settings, identity: identity ? { issuer: identity.issuer } : null });
141
225
  });
142
226
 
143
- // Generic invoke: the kernel checks a permission inside EVERY operation, so a
144
- // generic route is exactly as safe as one route per operation.
145
- app.post('/api/invoke', async (c) => {
146
- const { op, input } = await c.req.json<{ op: string; input?: unknown }>();
147
- return c.json((await (await stub(c)).invoke(op, input)) ?? null);
148
- });
227
+ // ── The vertical's API — the SAME table `server.ts` mounts (src/routes.ts) ───
228
+ // Including `/api/invoke`. Mounted BEFORE the platform surface: Hono keeps only the
229
+ // last-registered `onError`, so the platform's envelope wins for the whole app — which
230
+ // is harmless because both handlers classify through the same `classifyError`.
231
+ mountApi(app, stub);
149
232
 
150
233
  // ── /internal/* — the platform-gated management contract ────────────────────
151
234
  // The control plane provisions, heals, inspects and restores installs through
@@ -173,6 +256,15 @@ mountPlatformSurface<Env>(app, {
173
256
  onDeleteScope: async (env, s) => {
174
257
  await sweeper(env).forgetScope(s);
175
258
  },
259
+ // Per-instance config delivery (the dashboard's Settings → Env and Identity tabs).
260
+ // WITHOUT this hook `/internal/configure` answers 501 for the life of the app: the
261
+ // dashboard saves the setting, reports `delivered: false`, and the running worker
262
+ // never sees it — including the `substrat:auth` issuer choice that is the difference
263
+ // between a working login and 401-on-everything. Store it; `instanceConfig` reads it
264
+ // back. Idempotent, so the platform's reconciliation sweep can re-deliver safely.
265
+ onConfigure: async (env, b) => {
266
+ await configDo(env, { tenantId: b.tenantId, scopeId: b.scopeId }).setScopeConfig(b.scopeId, b.entries);
267
+ },
176
268
  });
177
269
 
178
270
  // Unmatched /api/* fails as JSON; everything else gets a pointer, not a UI —
@@ -181,7 +273,7 @@ app.all('/api/*', (c) => c.json({ error: `unknown route: ${new URL(c.req.raw.url
181
273
  app.all('*', (c) =>
182
274
  c.json({
183
275
  service: 'substrat vertical',
184
- api: 'POST /api/invoke { op, input }',
276
+ api: 'POST /api/invoke { op, input } — plus the named routes in src/routes.ts',
185
277
  docs: 'https://substrat.net',
186
278
  }),
187
279
  );
@@ -9,5 +9,5 @@
9
9
  "skipLibCheck": true,
10
10
  "noEmit": true
11
11
  },
12
- "include": ["src/worker.ts", "src/provision.ts", "src/manifest.ts", "src/migrations.ts", "src/module.ts"]
12
+ "include": ["src/worker.ts", "src/routes.ts", "src/config-do.ts", "src/provision.ts", "src/manifest.ts", "src/migrations.ts", "src/module.ts"]
13
13
  }