okengine 0.19.9 → 0.21.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/AGENTS.md +1 -1
- package/package.json +2 -1
- package/site/content/docs/client/auth.mdx +1 -2
- package/site/content/docs/client/calling.mdx +125 -37
- package/site/content/docs/client/index.mdx +7 -7
- package/site/content/docs/client/react.mdx +5 -0
- package/site/content/docs/elements/channel/email.mdx +25 -36
- package/site/content/docs/elements/channel/index.mdx +88 -46
- package/site/content/docs/elements/channel/push.mdx +7 -9
- package/site/content/docs/elements/channel/sms.mdx +6 -4
- package/site/content/docs/elements/channel/whatsapp.mdx +11 -9
- package/site/content/docs/elements/clock/index.mdx +14 -25
- package/site/content/docs/elements/flow/http.mdx +42 -23
- package/site/content/docs/elements/flow/index.mdx +41 -30
- package/site/content/docs/elements/flow/routing.mdx +166 -122
- package/site/content/docs/elements/gate/rls.mdx +2 -2
- package/site/content/docs/elements/gate/tenancy.mdx +1 -1
- package/site/content/docs/elements/store/files.mdx +11 -5
- package/site/content/docs/elements/store/index.mdx +13 -6
- package/site/content/docs/elements/store/kv.mdx +8 -2
- package/site/content/docs/elements/store/search.mdx +5 -5
- package/site/content/docs/elements/store/sql.mdx +100 -39
- package/site/content/docs/elements/vault/index.mdx +14 -13
- package/site/content/docs/elements/vault/secrets.mdx +6 -3
- package/site/content/docs/index.mdx +1 -1
- package/site/content/docs/plugins/magic-link.mdx +4 -3
- package/site/content/docs/plugins/otp.mdx +4 -3
- package/site/content/docs/plugins/two-factor.mdx +4 -0
- package/site/content/docs/providers/index.mdx +1 -1
- package/site/content/docs/recipes/index.mdx +1 -1
- package/site/content/docs/recipes/rustfs.mdx +1 -1
- package/site/content/docs/reference/cli.mdx +7 -4
- package/site/content/docs/reference/configuration.mdx +8 -8
- package/site/content/docs/reference/errors.mdx +229 -55
- package/site/content/docs/reference/fx.mdx +22 -9
- package/site/content/docs/reference/i18n.mdx +4 -4
- package/site/content/docs/reference/plugins.mdx +4 -4
- package/site/content/docs/understand/the-architecture.mdx +2 -2
- package/site/content/docs/understand/try-it.mdx +759 -24
- package/src/cli/ai-setup/ai-setup.test.ts +40 -0
- package/src/cli/ai-setup/apply.ts +28 -46
- package/src/cli/build.test.ts +3 -3
- package/src/cli/build.ts +5 -5
- package/src/cli/db-auto-push.test.ts +11 -0
- package/src/cli/db-auto-push.ts +6 -2
- package/src/cli/db.test.ts +1 -1
- package/src/cli/db.ts +6 -6
- package/src/cli/dev-app-runner.ts +2 -1
- package/src/cli/dev-db-push.test.ts +6 -2
- package/src/cli/dev-schema-sync.ts +1 -1
- package/src/cli/dev.test.ts +10 -7
- package/src/cli/dev.ts +13 -11
- package/src/cli/ensure-drizzle-config.ts +4 -3
- package/src/cli/start.ts +2 -1
- package/src/client/create.ts +3 -3
- package/src/client/explain.test.ts +252 -0
- package/src/client/explain.ts +272 -0
- package/src/client/live.test.ts +44 -0
- package/src/client/notes-contract.test.ts +10 -0
- package/src/client/sse.ts +6 -2
- package/src/client/transport.test.ts +67 -0
- package/src/client/transport.ts +24 -40
- package/src/client/types.ts +17 -6
- package/src/client-react/browser.test.ts +23 -0
- package/src/client-react/live-resource.ts +6 -2
- package/src/client-react/use-live-query.ts +1 -1
- package/src/compiler/flow-path.test.ts +1 -0
- package/src/compiler/flow-path.ts +1 -1
- package/src/compiler/generate-adopt.test.ts +55 -1
- package/src/compiler/generate-adopt.ts +111 -21
- package/src/compiler/response.ts +17 -27
- package/src/config/index.ts +6 -4
- package/src/console/server/invoke-user-flow.test.ts +8 -2
- package/src/console/server/invoke-user-flow.ts +12 -18
- package/src/console/server/security.gate.test.ts +1 -1
- package/src/console/ui-next/dist/assets/{access-page-BoC83Ubl.js → access-page-tIbsiphz.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-CKjAEOqA.js → agent-disclosure-CSKumwS2.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-Ceaq9pYh.js → cache-glyph-BCC-DxKT.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-C4lmY7ck.js → call-pii-button-jOmYISdc.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-82y257sL.js → collapsible-DC2xNaAb.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-W63jaMZ8.js → duration-tone-CwoV56jn.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-KFTFj2rK.js → flows-page-gT1lsWQK.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-yE9zNTqC.js → highlighted-json-C2GZEJNI.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-CwFeFroN.js → http-method-BdYjcIrD.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-r7xXt_VV.js → index-Cul17AcV.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-BDiliMiC.js → observability-page-oY9vdYBk.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-DYDzWUFT.js → replica-lag-C4QdAF7J.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-CzrOfgiz.js → request-meta-C43DHyld.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-CZC2cwaw.js → store-page-CL0D9dOq.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-DgeeejW7.js → trace-detail-sheet-Dcpo4Us_.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CzGIyOPY.js → tree-expand-toggle-CKJTuv43.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-DBiDCLIB.js → units-page-1PlT19ft.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-CcHsthPe.js → vault-page-BwZ9YjTW.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/docker/docker.test.ts +3 -3
- package/src/docker/images-config.test.ts +4 -4
- package/src/docker/stack-id.test.ts +1 -1
- package/src/drivers/clock-postgres.test.ts +10 -2
- package/src/drivers/clock-postgres.ts +18 -2
- package/src/drivers/vault-driver-removal.test.ts +2 -2
- package/src/elements/channel/declare.ts +66 -3
- package/src/elements/channel/runtime.ts +9 -11
- package/src/elements/channel.test.ts +42 -0
- package/src/elements/channel.ts +4 -2
- package/src/elements/clock/reconcile.ts +45 -24
- package/src/elements/clock.test.ts +33 -0
- package/src/elements/store/emit-drizzle.ts +285 -65
- package/src/elements/store/files-errors.test.ts +149 -0
- package/src/elements/store/files-errors.ts +189 -0
- package/src/elements/store/kv-errors.test.ts +98 -0
- package/src/elements/store/kv-errors.ts +139 -0
- package/src/elements/store/load-plugin-tables.ts +1 -1
- package/src/elements/store/prepare-row.test.ts +57 -4
- package/src/elements/store/resource.ts +11 -7
- package/src/elements/store/runtime.ts +18 -13
- package/src/elements/store/schema-decl.test.ts +178 -0
- package/src/elements/store/sql-errors.test.ts +197 -0
- package/src/elements/store/sql-errors.ts +294 -0
- package/src/elements/store/sql-session.test.ts +52 -0
- package/src/elements/store/sql-session.ts +50 -2
- package/src/elements/store/store-errors.ts +47 -0
- package/src/elements/store/table.ts +8 -6
- package/src/http.ts +9 -1
- package/src/i18n/catalogs/ar.ts +18 -0
- package/src/i18n/catalogs/en.ts +18 -0
- package/src/index.ts +9 -1
- package/src/kernel/adopt-barrel-fresh.test.ts +1 -1
- package/src/kernel/app.ts +40 -34
- package/src/kernel/auto-registry.test.ts +26 -1
- package/src/kernel/boot.ts +2 -2
- package/src/kernel/boundary-contract.ts +6 -1
- package/src/kernel/builtin-errors.test.ts +117 -0
- package/src/kernel/builtin-errors.ts +129 -0
- package/src/kernel/call.test.ts +182 -0
- package/src/kernel/errors-vault.ts +16 -0
- package/src/kernel/errors.registry.test.ts +7 -0
- package/src/kernel/errors.ts +97 -24
- package/src/kernel/fail-helpers.ts +34 -0
- package/src/kernel/flow-units.ts +3 -3
- package/src/kernel/fx.test.ts +8 -0
- package/src/kernel/fx.ts +24 -8
- package/src/kernel/index.ts +12 -1
- package/src/kernel/mutation-id.ts +8 -0
- package/src/kernel/plugin.ts +4 -3
- package/src/kernel/project-out.test.ts +176 -0
- package/src/kernel/project-out.ts +91 -0
- package/src/kernel/realtime-bind.ts +2 -3
- package/src/kernel/router/linear.ts +12 -6
- package/src/kernel/router.test.ts +13 -0
- package/src/plugins/magic-link.ts +25 -24
- package/src/plugins/otp.ts +35 -24
- package/src/plugins/two-factor.ts +15 -0
- package/src/runs/duckdb.test.ts +2 -2
- package/src/runtime/dev-request-log.ts +29 -11
- package/src/term.test.ts +76 -0
- package/src/term.ts +166 -3
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow `out` projection — store rows through ISO `out` without DTO mappers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { oke } from "./app.ts";
|
|
8
|
+
import { flow, resetFlowSeq } from "./flow.ts";
|
|
9
|
+
import { isJsonResult } from "./fx.ts";
|
|
10
|
+
import { on, resetBindings } from "./on.ts";
|
|
11
|
+
import { projectFlowOut } from "./project-out.ts";
|
|
12
|
+
import { http } from "./triggers.ts";
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
resetBindings();
|
|
16
|
+
resetFlowSeq();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const IsoInstant = z.iso.datetime();
|
|
20
|
+
|
|
21
|
+
const LinkOut = z.object({
|
|
22
|
+
id: z.string(),
|
|
23
|
+
clicks: z.number(),
|
|
24
|
+
expiresAt: IsoInstant.nullable(),
|
|
25
|
+
createdAt: IsoInstant,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("projectFlowOut", () => {
|
|
29
|
+
test("JSON-wires Date timestamps onto iso.datetime out", async () => {
|
|
30
|
+
const createdAt = new Date("2026-09-14T12:00:00.000Z");
|
|
31
|
+
const projected = await projectFlowOut(LinkOut, {
|
|
32
|
+
id: "lnk_1",
|
|
33
|
+
clicks: 0,
|
|
34
|
+
expiresAt: null,
|
|
35
|
+
createdAt,
|
|
36
|
+
extra: "drop-me",
|
|
37
|
+
});
|
|
38
|
+
expect(projected).toEqual({
|
|
39
|
+
id: "lnk_1",
|
|
40
|
+
clicks: 0,
|
|
41
|
+
expiresAt: null,
|
|
42
|
+
createdAt: "2026-09-14T12:00:00.000Z",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("JSON-wires fx.clock.now()-style epoch-ms *At onto iso.datetime out", async () => {
|
|
47
|
+
const createdAt = Date.parse("2026-09-14T12:00:00.000Z");
|
|
48
|
+
const projected = await projectFlowOut(LinkOut, {
|
|
49
|
+
id: "lnk_1",
|
|
50
|
+
clicks: 0,
|
|
51
|
+
expiresAt: null,
|
|
52
|
+
createdAt,
|
|
53
|
+
});
|
|
54
|
+
expect(projected).toEqual({
|
|
55
|
+
id: "lnk_1",
|
|
56
|
+
clicks: 0,
|
|
57
|
+
expiresAt: null,
|
|
58
|
+
createdAt: "2026-09-14T12:00:00.000Z",
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("fx.json.create carrier projects .value and keeps 201", async () => {
|
|
63
|
+
const createdAt = new Date("2026-09-14T12:00:00.000Z");
|
|
64
|
+
const fx = (await import("./fx.ts")).createFx({ flow: "links.create" });
|
|
65
|
+
const carrier = await projectFlowOut(
|
|
66
|
+
LinkOut,
|
|
67
|
+
fx.json.create({
|
|
68
|
+
id: "lnk_1",
|
|
69
|
+
clicks: 0,
|
|
70
|
+
expiresAt: null,
|
|
71
|
+
createdAt,
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
expect(isJsonResult(carrier)).toBe(true);
|
|
75
|
+
if (!isJsonResult(carrier)) return;
|
|
76
|
+
expect(carrier.status).toBe(201);
|
|
77
|
+
expect(carrier.value).toEqual({
|
|
78
|
+
id: "lnk_1",
|
|
79
|
+
clicks: 0,
|
|
80
|
+
expiresAt: null,
|
|
81
|
+
createdAt: "2026-09-14T12:00:00.000Z",
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("z.date() out keeps Date (direct parse, no wire)", async () => {
|
|
86
|
+
const createdAt = new Date("2026-09-14T12:00:00.000Z");
|
|
87
|
+
const Out = z.object({ createdAt: z.date() });
|
|
88
|
+
const projected = await projectFlowOut(Out, { createdAt });
|
|
89
|
+
expect(projected).toEqual({ createdAt });
|
|
90
|
+
expect((projected as { createdAt: Date }).createdAt).toBe(createdAt);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("array out projects each row", async () => {
|
|
94
|
+
const createdAt = new Date("2026-09-14T12:00:00.000Z");
|
|
95
|
+
const projected = await projectFlowOut(z.array(LinkOut), [
|
|
96
|
+
{ id: "lnk_1", clicks: 1, expiresAt: null, createdAt },
|
|
97
|
+
]);
|
|
98
|
+
expect(projected).toEqual([
|
|
99
|
+
{ id: "lnk_1", clicks: 1, expiresAt: null, createdAt: "2026-09-14T12:00:00.000Z" },
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("bigint integers JSON-wire onto z.number()", async () => {
|
|
104
|
+
const Out = z.object({ clicks: z.number() });
|
|
105
|
+
const projected = await projectFlowOut(Out, { clicks: 3n });
|
|
106
|
+
expect(projected).toEqual({ clicks: 3 });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("missing out is a no-op", async () => {
|
|
110
|
+
const row = { id: "lnk_1", createdAt: new Date() };
|
|
111
|
+
expect(await projectFlowOut(undefined, row)).toBe(row);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("execute — out projects fx.json.create(row)", () => {
|
|
116
|
+
test("in-process create returns ISO timestamps from a Date row", async () => {
|
|
117
|
+
resetBindings();
|
|
118
|
+
resetFlowSeq();
|
|
119
|
+
const createdAt = new Date("2026-09-14T12:00:00.000Z");
|
|
120
|
+
on(
|
|
121
|
+
http.post("/links", { out: LinkOut }).public(),
|
|
122
|
+
flow("links.create", {
|
|
123
|
+
do: (_input, fx) =>
|
|
124
|
+
fx.json.create({
|
|
125
|
+
id: "lnk_1",
|
|
126
|
+
clicks: 0,
|
|
127
|
+
expiresAt: null,
|
|
128
|
+
createdAt,
|
|
129
|
+
secret: "not-on-the-wire",
|
|
130
|
+
}),
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
const app = oke({ name: "project-out", env: "test" });
|
|
134
|
+
await app.boot({ env: "test" });
|
|
135
|
+
const flowDef = app.flow("links.create")!;
|
|
136
|
+
const result = await app.execute(flowDef, {}, flowDef.triggers[0]!);
|
|
137
|
+
expect(result.failure).toBeUndefined();
|
|
138
|
+
expect(isJsonResult(result.output)).toBe(true);
|
|
139
|
+
if (!isJsonResult(result.output)) return;
|
|
140
|
+
expect(result.output.status).toBe(201);
|
|
141
|
+
expect(result.output.value).toEqual({
|
|
142
|
+
id: "lnk_1",
|
|
143
|
+
clicks: 0,
|
|
144
|
+
expiresAt: null,
|
|
145
|
+
createdAt: "2026-09-14T12:00:00.000Z",
|
|
146
|
+
});
|
|
147
|
+
await app.bootResult?.close();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("withQuery page projects Date rows onto array out", async () => {
|
|
151
|
+
resetBindings();
|
|
152
|
+
resetFlowSeq();
|
|
153
|
+
const createdAt = new Date("2026-09-14T12:00:00.000Z");
|
|
154
|
+
on(
|
|
155
|
+
http.get("/links", { out: z.array(LinkOut) }).public(),
|
|
156
|
+
flow("links.list", {
|
|
157
|
+
do: (input, fx) =>
|
|
158
|
+
fx.json.withQuery(
|
|
159
|
+
[{ id: "lnk_1", clicks: 0, expiresAt: null, createdAt, extra: true }],
|
|
160
|
+
input,
|
|
161
|
+
),
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
const app = oke({ name: "project-out-list", env: "test" });
|
|
165
|
+
await app.boot({ env: "test" });
|
|
166
|
+
const flowDef = app.flow("links.list")!;
|
|
167
|
+
const result = await app.execute(flowDef, { limit: 10 }, flowDef.triggers[0]!);
|
|
168
|
+
expect(result.failure).toBeUndefined();
|
|
169
|
+
expect(isJsonResult(result.output)).toBe(true);
|
|
170
|
+
if (!isJsonResult(result.output)) return;
|
|
171
|
+
expect(result.output.value).toEqual([
|
|
172
|
+
{ id: "lnk_1", clicks: 0, expiresAt: null, createdAt: "2026-09-14T12:00:00.000Z" },
|
|
173
|
+
]);
|
|
174
|
+
await app.bootResult?.close();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project a Flow success value onto the exposure `out` schema.
|
|
3
|
+
*
|
|
4
|
+
* Store rows carry `Date` timestamps (or epoch-ms from `fx.clock.now()`);
|
|
5
|
+
* HTTP `out` is usually ISO-8601. Direct Standard Schema parse keeps
|
|
6
|
+
* `z.date()` replies as dates. When that fails, JSON-wire (`Date` → ISO,
|
|
7
|
+
* `*At` / `*_at` / `at` epoch-ms → ISO) and parse again so
|
|
8
|
+
* `fx.json.create(row)` matches `out` without a hand-written DTO mapper.
|
|
9
|
+
* Extra keys strip when parse succeeds. Parse failure is not a client
|
|
10
|
+
* error — `out` is a projector, not a hard gate.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { SchemaInput } from "../validation/standard-schema.ts";
|
|
14
|
+
import { validate } from "../validation/standard-schema.ts";
|
|
15
|
+
import { isJsonResult, isJsonStreamResult, jsonResultBrand, type JsonResult } from "./fx.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Project `output` through `schema` when the exposure declared `out`.
|
|
19
|
+
*
|
|
20
|
+
* JsonResult carriers project `.value` (status / meta stay). Streams, 204,
|
|
21
|
+
* and missing schema pass through.
|
|
22
|
+
*
|
|
23
|
+
* @param schema - Flow `out` (Standard Schema or unknown)
|
|
24
|
+
* @param output - Handler return
|
|
25
|
+
*/
|
|
26
|
+
export async function projectFlowOut(
|
|
27
|
+
schema: SchemaInput | undefined,
|
|
28
|
+
output: unknown,
|
|
29
|
+
): Promise<unknown> {
|
|
30
|
+
if (schema === undefined || schema === null) return output;
|
|
31
|
+
if (output === undefined) return output;
|
|
32
|
+
if (isJsonStreamResult(output)) return output;
|
|
33
|
+
if (isJsonResult(output)) {
|
|
34
|
+
if (output.status === 204 || output.value === undefined) return output;
|
|
35
|
+
const value = await projectValue(schema, output.value);
|
|
36
|
+
if (value === output.value) return output;
|
|
37
|
+
return {
|
|
38
|
+
[jsonResultBrand]: true,
|
|
39
|
+
status: output.status,
|
|
40
|
+
value,
|
|
41
|
+
...(output.meta !== undefined ? { meta: output.meta } : {}),
|
|
42
|
+
} as JsonResult;
|
|
43
|
+
}
|
|
44
|
+
return projectValue(schema, output);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse `value` as `schema`. On miss, JSON-wire then parse (Date / epoch-ms → ISO).
|
|
49
|
+
*
|
|
50
|
+
* @param schema - Success reply schema
|
|
51
|
+
* @param value - Raw handler / store value
|
|
52
|
+
*/
|
|
53
|
+
async function projectValue(schema: SchemaInput, value: unknown): Promise<unknown> {
|
|
54
|
+
const direct = await validate(schema, value);
|
|
55
|
+
if (direct.ok) return direct.value;
|
|
56
|
+
const wired = jsonWire(value);
|
|
57
|
+
if (wired === value) return value;
|
|
58
|
+
const second = await validate(schema, wired);
|
|
59
|
+
return second.ok ? second.value : value;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether a JSON key looks like a temporal field (`createdAt`, `expires_at`, `at`).
|
|
64
|
+
*
|
|
65
|
+
* @param key - Property name from {@link JSON.stringify} replacer
|
|
66
|
+
*/
|
|
67
|
+
function isTemporalKey(key: string): boolean {
|
|
68
|
+
return key === "at" || key.endsWith("At") || key.endsWith("_at");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* JSON round-trip so `Date` and temporal epoch-ms become ISO-8601
|
|
73
|
+
* (same bytes `Response.json` emits for dates).
|
|
74
|
+
*
|
|
75
|
+
* @param value - Arbitrary success payload
|
|
76
|
+
*/
|
|
77
|
+
function jsonWire(value: unknown): unknown {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(
|
|
80
|
+
JSON.stringify(value, (key, item) => {
|
|
81
|
+
if (typeof item === "bigint") return Number(item);
|
|
82
|
+
if (typeof item === "number" && Number.isFinite(item) && isTemporalKey(key)) {
|
|
83
|
+
return new Date(item).toISOString();
|
|
84
|
+
}
|
|
85
|
+
return item;
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
} catch {
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -22,6 +22,8 @@ import { setSqlCdcSink, type SqlCdcSink } from "../elements/store/sql-session.ts
|
|
|
22
22
|
import type { LiveQueryEvent, LiveSubscription } from "../elements/store/live-query-runtime.ts";
|
|
23
23
|
import { currentAbortSignal, linkAbort } from "./abort-scope.ts";
|
|
24
24
|
|
|
25
|
+
export { MUTATION_ID_HEADER } from "./mutation-id.ts";
|
|
26
|
+
|
|
25
27
|
/**
|
|
26
28
|
* Column-kind hint for one SQL column — drives {@link restoreImage} type
|
|
27
29
|
* restoration for outbox-round-tripped CDC images.
|
|
@@ -41,9 +43,6 @@ export function liveSignalName(table: string): string {
|
|
|
41
43
|
return `${LIVE_SIGNAL_PREFIX}${table}`;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
/** Mutation header echoed into `mutationId` on write-path events. */
|
|
45
|
-
export const MUTATION_ID_HEADER = "x-oke-mutation-id";
|
|
46
|
-
|
|
47
46
|
/** Per-stream event buffer cap (bounded memory per SSE session). */
|
|
48
47
|
const LIVE_STREAM_BUFFER_MAX = 512;
|
|
49
48
|
|
|
@@ -31,6 +31,9 @@ export class LinearRouter<T> implements Router<T> {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
+
* Match a method + pathname. Static routes win over `/:param` / `*` even
|
|
35
|
+
* when the parametric route was registered first.
|
|
36
|
+
*
|
|
34
37
|
* @param method - HTTP method
|
|
35
38
|
* @param path - Pathname
|
|
36
39
|
*/
|
|
@@ -40,15 +43,18 @@ export class LinearRouter<T> implements Router<T> {
|
|
|
40
43
|
pathParts.pop();
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
// Static first — `GET /health` must win over an earlier `GET /:code`.
|
|
43
47
|
for (const route of this.#routes) {
|
|
44
48
|
if (route.method !== method) continue;
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
return { value: route.value, params: {} };
|
|
49
|
-
}
|
|
50
|
-
continue;
|
|
49
|
+
if (route.path.includes(":") || route.path.includes("*")) continue;
|
|
50
|
+
if (route.path === path || `${route.path}/` === path) {
|
|
51
|
+
return { value: route.value, params: {} };
|
|
51
52
|
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const route of this.#routes) {
|
|
56
|
+
if (route.method !== method) continue;
|
|
57
|
+
if (!route.path.includes(":") && !route.path.includes("*")) continue;
|
|
52
58
|
|
|
53
59
|
const params: Record<string, string> = {};
|
|
54
60
|
let pi = 0;
|
|
@@ -71,6 +71,19 @@ describe("router — RegExp + Trie + Linear + Smart", () => {
|
|
|
71
71
|
expect(r.match("GET", "/x/1")).toEqual({ value: "x", params: { id: "1" } });
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
test("LinearRouter prefers static over an earlier /:param", () => {
|
|
75
|
+
const linear = new LinearRouter<string>();
|
|
76
|
+
linear.add("GET", "/:code", "redirect");
|
|
77
|
+
linear.add("GET", "/health", "health");
|
|
78
|
+
linear.add("GET", "/links", "list");
|
|
79
|
+
expect(linear.match("GET", "/health")?.value).toBe("health");
|
|
80
|
+
expect(linear.match("GET", "/links")?.value).toBe("list");
|
|
81
|
+
expect(linear.match("GET", "/abc")).toEqual({
|
|
82
|
+
value: "redirect",
|
|
83
|
+
params: { code: "abc" },
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
74
87
|
test("allowedMethods lists verbs for a path and is empty for unknown paths", () => {
|
|
75
88
|
const regexp = new RegExpRouter<string>();
|
|
76
89
|
regexp.add("POST", "/notes", "create");
|
|
@@ -40,31 +40,36 @@ const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
|
40
40
|
const DEFAULT_FROM = "OKE <no-reply@oke.local>";
|
|
41
41
|
const DEFAULT_BASE_URL = "http://127.0.0.1:6530";
|
|
42
42
|
|
|
43
|
+
const MAGIC_LINK_SCHEMA = z.object({
|
|
44
|
+
email: z.string(),
|
|
45
|
+
token: z.string(),
|
|
46
|
+
link: z.string(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const MAGIC_LINK_BODIES = {
|
|
50
|
+
en: {
|
|
51
|
+
subject: "Your sign-in link",
|
|
52
|
+
text: "Sign in with this link:\n{{link}}\n\nOr enter this token:\n{{token}}\n",
|
|
53
|
+
html: '<p>Sign in with this link:</p><p><a href="{{link}}">{{link}}</a></p><p>Or enter this token:</p><p><code>{{token}}</code></p>',
|
|
54
|
+
},
|
|
55
|
+
ar: {
|
|
56
|
+
subject: "رابط تسجيل الدخول",
|
|
57
|
+
text: "سجّل الدخول عبر هذا الرابط:\n{{link}}\n\nأو أدخل هذا الرمز:\n{{token}}\n",
|
|
58
|
+
html: '<p dir="rtl">سجّل الدخول عبر هذا الرابط:</p><p dir="rtl"><a href="{{link}}">{{link}}</a></p><p dir="rtl">أو أدخل هذا الرمز:</p><p dir="rtl"><code>{{token}}</code></p>',
|
|
59
|
+
},
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
43
62
|
/** Channel template for magic-link delivery. */
|
|
44
63
|
export const magicLinkTemplate = channel.email({ from: DEFAULT_FROM }).template("auth-magic-link", {
|
|
45
64
|
description: "Magic-link sign-in email",
|
|
46
|
-
schema:
|
|
47
|
-
email: z.string(),
|
|
48
|
-
token: z.string(),
|
|
49
|
-
link: z.string(),
|
|
50
|
-
}),
|
|
65
|
+
schema: MAGIC_LINK_SCHEMA,
|
|
51
66
|
locales: ["en", "ar"],
|
|
67
|
+
catalog: MAGIC_LINK_BODIES,
|
|
52
68
|
});
|
|
53
69
|
|
|
54
|
-
/** Default EN/AR bodies for {@link magicLinkTemplate}. */
|
|
70
|
+
/** Default EN/AR bodies for {@link magicLinkTemplate} (overlay / tests). */
|
|
55
71
|
export const magicLinkCatalog = {
|
|
56
|
-
"auth-magic-link":
|
|
57
|
-
en: {
|
|
58
|
-
subject: "Your sign-in link",
|
|
59
|
-
text: "Sign in with this link:\n{{link}}\n\nOr enter this token:\n{{token}}\n",
|
|
60
|
-
html: '<p>Sign in with this link:</p><p><a href="{{link}}">{{link}}</a></p><p>Or enter this token:</p><p><code>{{token}}</code></p>',
|
|
61
|
-
},
|
|
62
|
-
ar: {
|
|
63
|
-
subject: "رابط تسجيل الدخول",
|
|
64
|
-
text: "سجّل الدخول عبر هذا الرابط:\n{{link}}\n\nأو أدخل هذا الرمز:\n{{token}}\n",
|
|
65
|
-
html: '<p dir="rtl">سجّل الدخول عبر هذا الرابط:</p><p dir="rtl"><a href="{{link}}">{{link}}</a></p><p dir="rtl">أو أدخل هذا الرمز:</p><p dir="rtl"><code>{{token}}</code></p>',
|
|
66
|
-
},
|
|
67
|
-
},
|
|
72
|
+
"auth-magic-link": MAGIC_LINK_BODIES,
|
|
68
73
|
} as const;
|
|
69
74
|
|
|
70
75
|
/** Options for {@link magicLink}. */
|
|
@@ -101,12 +106,9 @@ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
|
|
|
101
106
|
opts.from !== undefined
|
|
102
107
|
? channel.email({ from: opts.from }).template("auth-magic-link", {
|
|
103
108
|
description: "Magic-link sign-in email",
|
|
104
|
-
schema:
|
|
105
|
-
email: z.string(),
|
|
106
|
-
token: z.string(),
|
|
107
|
-
link: z.string(),
|
|
108
|
-
}),
|
|
109
|
+
schema: MAGIC_LINK_SCHEMA,
|
|
109
110
|
locales: ["en", "ar"],
|
|
111
|
+
catalog: MAGIC_LINK_BODIES,
|
|
110
112
|
})
|
|
111
113
|
: magicLinkTemplate;
|
|
112
114
|
|
|
@@ -213,7 +215,6 @@ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
|
|
|
213
215
|
.needs("auth")
|
|
214
216
|
.needs("channel")
|
|
215
217
|
.channelTemplate(tmpl)
|
|
216
|
-
.channelCatalog(magicLinkCatalog)
|
|
217
218
|
.binding(bindPublicAuth("/magic-link/request", request, "otp", requestContract))
|
|
218
219
|
.binding(bindPublicAuth("/magic-link/verify", verify, "otp", verifyContract));
|
|
219
220
|
}
|
package/src/plugins/otp.ts
CHANGED
|
@@ -94,11 +94,37 @@ export interface OtpAppModeOptions extends OtpBaseOptions {
|
|
|
94
94
|
/** Options for {@link otp}. `mode` is mandatory — no auto-detect. */
|
|
95
95
|
export type OtpOptions = OtpProviderModeOptions | OtpAppModeOptions;
|
|
96
96
|
|
|
97
|
+
const OTP_EMAIL_BODIES = {
|
|
98
|
+
en: {
|
|
99
|
+
subject: "Your sign-in code",
|
|
100
|
+
text: "Your one-time sign-in code is: {{otp}}\n",
|
|
101
|
+
html: "<p>Your one-time sign-in code is:</p><p><strong>{{otp}}</strong></p>",
|
|
102
|
+
},
|
|
103
|
+
ar: {
|
|
104
|
+
subject: "رمز تسجيل الدخول",
|
|
105
|
+
text: "رمز تسجيل الدخول لمرة واحدة هو: {{otp}}\n",
|
|
106
|
+
html: '<p dir="rtl">رمز تسجيل الدخول لمرة واحدة هو:</p><p dir="rtl"><strong>{{otp}}</strong></p>',
|
|
107
|
+
},
|
|
108
|
+
} as const;
|
|
109
|
+
|
|
110
|
+
const OTP_SMS_BODIES = {
|
|
111
|
+
en: { text: "Your sign-in code is: {{otp}}" },
|
|
112
|
+
ar: { text: "رمز تسجيل الدخول: {{otp}}" },
|
|
113
|
+
} as const;
|
|
114
|
+
|
|
115
|
+
const OTP_WHATSAPP_BODIES = {
|
|
116
|
+
en: { text: "Your sign-in code is: {{otp}}" },
|
|
117
|
+
ar: { text: "رمز تسجيل الدخول: {{otp}}" },
|
|
118
|
+
} as const;
|
|
119
|
+
|
|
120
|
+
const OTP_EMAIL_SCHEMA = z.object({ email: z.string(), otp: z.string() });
|
|
121
|
+
|
|
97
122
|
/** Email OTP template (app mode). */
|
|
98
123
|
export const otpEmailTemplate = channel.email({ from: DEFAULT_FROM }).template("auth-otp-email", {
|
|
99
124
|
description: "OTP sign-in code (email)",
|
|
100
|
-
schema:
|
|
125
|
+
schema: OTP_EMAIL_SCHEMA,
|
|
101
126
|
locales: ["en", "ar"],
|
|
127
|
+
catalog: OTP_EMAIL_BODIES,
|
|
102
128
|
});
|
|
103
129
|
|
|
104
130
|
/** SMS OTP template (app mode — plain message, not provider Verify). */
|
|
@@ -106,6 +132,7 @@ export const otpSmsTemplate = channel.sms().template("auth-otp-sms", {
|
|
|
106
132
|
description: "OTP sign-in code (SMS)",
|
|
107
133
|
schema: z.object({ phone: z.string(), otp: z.string() }),
|
|
108
134
|
locales: ["en", "ar"],
|
|
135
|
+
catalog: OTP_SMS_BODIES,
|
|
109
136
|
});
|
|
110
137
|
|
|
111
138
|
/** WhatsApp OTP template (app mode). */
|
|
@@ -113,30 +140,14 @@ export const otpWhatsappTemplate = channel.whatsapp().template("auth-otp-whatsap
|
|
|
113
140
|
description: "OTP sign-in code (WhatsApp)",
|
|
114
141
|
schema: z.object({ phone: z.string(), otp: z.string() }),
|
|
115
142
|
locales: ["en", "ar"],
|
|
143
|
+
catalog: OTP_WHATSAPP_BODIES,
|
|
116
144
|
});
|
|
117
145
|
|
|
118
|
-
/** Default EN/AR bodies for OTP templates. */
|
|
146
|
+
/** Default EN/AR bodies for OTP templates (overlay / tests). */
|
|
119
147
|
export const otpCatalog = {
|
|
120
|
-
"auth-otp-email":
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
text: "Your one-time sign-in code is: {{otp}}\n",
|
|
124
|
-
html: "<p>Your one-time sign-in code is:</p><p><strong>{{otp}}</strong></p>",
|
|
125
|
-
},
|
|
126
|
-
ar: {
|
|
127
|
-
subject: "رمز تسجيل الدخول",
|
|
128
|
-
text: "رمز تسجيل الدخول لمرة واحدة هو: {{otp}}\n",
|
|
129
|
-
html: '<p dir="rtl">رمز تسجيل الدخول لمرة واحدة هو:</p><p dir="rtl"><strong>{{otp}}</strong></p>',
|
|
130
|
-
},
|
|
131
|
-
},
|
|
132
|
-
"auth-otp-sms": {
|
|
133
|
-
en: { text: "Your sign-in code is: {{otp}}" },
|
|
134
|
-
ar: { text: "رمز تسجيل الدخول: {{otp}}" },
|
|
135
|
-
},
|
|
136
|
-
"auth-otp-whatsapp": {
|
|
137
|
-
en: { text: "Your sign-in code is: {{otp}}" },
|
|
138
|
-
ar: { text: "رمز تسجيل الدخول: {{otp}}" },
|
|
139
|
-
},
|
|
148
|
+
"auth-otp-email": OTP_EMAIL_BODIES,
|
|
149
|
+
"auth-otp-sms": OTP_SMS_BODIES,
|
|
150
|
+
"auth-otp-whatsapp": OTP_WHATSAPP_BODIES,
|
|
140
151
|
} as const;
|
|
141
152
|
|
|
142
153
|
function assertOtpOptions(opts: OtpOptions): void {
|
|
@@ -217,8 +228,9 @@ export function otp(opts: OtpOptions): PluginDef {
|
|
|
217
228
|
from !== undefined
|
|
218
229
|
? channel.email({ from }).template("auth-otp-email", {
|
|
219
230
|
description: "OTP sign-in code (email)",
|
|
220
|
-
schema:
|
|
231
|
+
schema: OTP_EMAIL_SCHEMA,
|
|
221
232
|
locales: ["en", "ar"],
|
|
233
|
+
catalog: OTP_EMAIL_BODIES,
|
|
222
234
|
})
|
|
223
235
|
: otpEmailTemplate;
|
|
224
236
|
|
|
@@ -613,7 +625,6 @@ export function otp(opts: OtpOptions): PluginDef {
|
|
|
613
625
|
.channelTemplate(emailTmpl)
|
|
614
626
|
.channelTemplate(otpSmsTemplate)
|
|
615
627
|
.channelTemplate(otpWhatsappTemplate)
|
|
616
|
-
.channelCatalog(otpCatalog)
|
|
617
628
|
.binding(bindPublicAuth("/otp/request", request, "otp", requestContract))
|
|
618
629
|
.binding(bindPublicAuth("/otp/verify", verify, "otp", verifyContract))
|
|
619
630
|
.binding(bindPublicAuth("/otp/resend", resend, "otp", resendContract));
|
|
@@ -126,10 +126,24 @@ export function twoFactor(opts: TwoFactorOptions = {}): PluginDef {
|
|
|
126
126
|
opts.verifications ?? active?.twoFactorVerifications ?? createVerificationStore();
|
|
127
127
|
const exposeDevOtp = opts.exposeDevOtp === true;
|
|
128
128
|
|
|
129
|
+
const TWO_FACTOR_EMAIL_BODIES = {
|
|
130
|
+
en: {
|
|
131
|
+
subject: "Your two-factor code",
|
|
132
|
+
text: "Your two-factor sign-in code is: {{otp}}\n",
|
|
133
|
+
html: "<p>Your two-factor sign-in code is:</p><p><strong>{{otp}}</strong></p>",
|
|
134
|
+
},
|
|
135
|
+
ar: {
|
|
136
|
+
subject: "رمز التحقق بخطوتين",
|
|
137
|
+
text: "رمز التحقق بخطوتين هو: {{otp}}\n",
|
|
138
|
+
html: '<p dir="rtl">رمز التحقق بخطوتين هو:</p><p dir="rtl"><strong>{{otp}}</strong></p>',
|
|
139
|
+
},
|
|
140
|
+
} as const;
|
|
141
|
+
|
|
129
142
|
const emailTmpl = channel.email({ from: "OKE <no-reply@oke.local>" }).template("auth-2fa-email", {
|
|
130
143
|
description: "Two-factor email OTP",
|
|
131
144
|
schema: z.object({ email: z.string(), otp: z.string() }),
|
|
132
145
|
locales: ["en", "ar"],
|
|
146
|
+
catalog: TWO_FACTOR_EMAIL_BODIES,
|
|
133
147
|
});
|
|
134
148
|
|
|
135
149
|
async function provisionEmailOtp(userId: string, now: number): Promise<string | undefined> {
|
|
@@ -566,6 +580,7 @@ export function twoFactor(opts: TwoFactorOptions = {}): PluginDef {
|
|
|
566
580
|
|
|
567
581
|
return plugin("twoFactor", { version: "0.0.1", config: { method: "two-factor" } })
|
|
568
582
|
.needs("auth")
|
|
583
|
+
.channelTemplate(emailTmpl)
|
|
569
584
|
.table("oke_two_factor", undefined, { plane: "user", description: "TOTP secrets + recovery" })
|
|
570
585
|
.binding(bindSessionAuth("/two-factor/enable", enable, enableContract))
|
|
571
586
|
.binding(bindPublicAuth("/two-factor/verify", verify, "otp", verifyContract))
|
package/src/runs/duckdb.test.ts
CHANGED
|
@@ -19,9 +19,9 @@ describe("openDuckDB", () => {
|
|
|
19
19
|
}
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
test("
|
|
22
|
+
test("create-oke templates declare @duckdb/node-api", () => {
|
|
23
23
|
const root = join(import.meta.dir, "../../packages/create-oke/templates");
|
|
24
|
-
for (const tier of ["
|
|
24
|
+
for (const tier of ["blank", "shorter"] as const) {
|
|
25
25
|
const pkg = JSON.parse(readFileSync(join(root, tier, "package.json"), "utf8")) as {
|
|
26
26
|
dependencies?: { "@duckdb/node-api"?: string };
|
|
27
27
|
trustedDependencies?: readonly string[];
|
|
@@ -56,6 +56,8 @@ export type DevRequestLogInput = {
|
|
|
56
56
|
readonly ms: number;
|
|
57
57
|
/** Human failure detail (error.message / code) for 4xx/5xx. */
|
|
58
58
|
readonly detail?: string;
|
|
59
|
+
/** Failure `error.code` for the title chip. */
|
|
60
|
+
readonly errorCode?: string;
|
|
59
61
|
};
|
|
60
62
|
|
|
61
63
|
/**
|
|
@@ -85,16 +87,19 @@ export function isSilentDevRequest(method: string, path: string, surface?: DevLo
|
|
|
85
87
|
*
|
|
86
88
|
* @param response - HTTP response (cloned; original body stays readable)
|
|
87
89
|
*/
|
|
88
|
-
export async function
|
|
89
|
-
|
|
90
|
+
export async function failureEnvelopeFromResponse(
|
|
91
|
+
response: Response,
|
|
92
|
+
): Promise<{ readonly detail?: string; readonly code?: string }> {
|
|
93
|
+
if (response.status < 400) return {};
|
|
90
94
|
try {
|
|
91
95
|
const body: unknown = await response.clone().json();
|
|
92
|
-
if (body === null || typeof body !== "object" || !("error" in body)) return
|
|
96
|
+
if (body === null || typeof body !== "object" || !("error" in body)) return {};
|
|
93
97
|
const error = (body as { error: unknown }).error;
|
|
94
|
-
if (error === null || typeof error !== "object") return
|
|
98
|
+
if (error === null || typeof error !== "object") return {};
|
|
95
99
|
const rec = error as { message?: unknown; code?: unknown; data?: unknown };
|
|
100
|
+
const code = typeof rec.code === "string" ? rec.code : undefined;
|
|
96
101
|
if (typeof rec.message === "string" && rec.message.trim().length > 0) {
|
|
97
|
-
return rec.message.trim();
|
|
102
|
+
return { detail: rec.message.trim(), ...(code ? { code } : {}) };
|
|
98
103
|
}
|
|
99
104
|
if (
|
|
100
105
|
rec.data !== null &&
|
|
@@ -103,14 +108,25 @@ export async function failureDetailFromResponse(response: Response): Promise<str
|
|
|
103
108
|
typeof (rec.data as { reason: unknown }).reason === "string"
|
|
104
109
|
) {
|
|
105
110
|
const reason = (rec.data as { reason: string }).reason;
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
return {
|
|
112
|
+
detail: `${code ?? "Error"}: ${reason}`,
|
|
113
|
+
...(code ? { code } : {}),
|
|
114
|
+
};
|
|
108
115
|
}
|
|
109
|
-
if (
|
|
116
|
+
if (code) return { detail: code, code };
|
|
110
117
|
} catch {
|
|
111
118
|
// non-JSON error bodies stay status-only
|
|
112
119
|
}
|
|
113
|
-
return
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Extract a short failure detail from an OKE JSON envelope body.
|
|
125
|
+
*
|
|
126
|
+
* @param response - HTTP response (cloned; original body stays readable)
|
|
127
|
+
*/
|
|
128
|
+
export async function failureDetailFromResponse(response: Response): Promise<string | undefined> {
|
|
129
|
+
return (await failureEnvelopeFromResponse(response)).detail;
|
|
114
130
|
}
|
|
115
131
|
|
|
116
132
|
/**
|
|
@@ -135,6 +151,7 @@ export function logDevRequest(input: DevRequestLogInput): void {
|
|
|
135
151
|
status: input.status,
|
|
136
152
|
ms: input.ms,
|
|
137
153
|
detail: input.detail,
|
|
154
|
+
errorCode: input.errorCode,
|
|
138
155
|
}),
|
|
139
156
|
);
|
|
140
157
|
}
|
|
@@ -167,7 +184,7 @@ export async function timedDevFetch(
|
|
|
167
184
|
const url = new URL(request.url);
|
|
168
185
|
const method = request.method.toUpperCase();
|
|
169
186
|
const response = await handle(request);
|
|
170
|
-
const
|
|
187
|
+
const envelope = await failureEnvelopeFromResponse(response);
|
|
171
188
|
logDevRequest({
|
|
172
189
|
surface: options.surface ?? currentDevSurface(),
|
|
173
190
|
method,
|
|
@@ -176,7 +193,8 @@ export async function timedDevFetch(
|
|
|
176
193
|
runId: options.resolveRunId?.(request, response),
|
|
177
194
|
status: response.status,
|
|
178
195
|
ms: Math.round(performance.now() - started),
|
|
179
|
-
detail,
|
|
196
|
+
detail: envelope.detail,
|
|
197
|
+
errorCode: envelope.code,
|
|
180
198
|
});
|
|
181
199
|
return response;
|
|
182
200
|
}
|