@konneal/engine 0.2.33 → 0.2.35
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/dist/apicalls.d.ts +72 -0
- package/dist/ask-P6KLPBKL.js +12 -0
- package/dist/{chunk-JGKSUSF5.js → chunk-4GJGBGJK.js} +3 -3
- package/dist/{chunk-NHHQAGBW.js → chunk-GHDRKT4K.js} +115 -25
- package/dist/{chunk-2RKYO3OC.js → chunk-R2V3X6SQ.js} +1 -1
- package/dist/{chunk-OMXAE27N.js → chunk-V46XM2GU.js} +4 -0
- package/dist/{chunk-WSLADX7O.js → chunk-YSWKFXXG.js} +306 -12
- package/dist/{chunk-5LSEBH3J.js → chunk-Z7AQEW6G.js} +4 -4
- package/dist/config.d.ts +1 -0
- package/dist/context.d.ts +37 -0
- package/dist/delegated.d.ts +2 -0
- package/dist/drafts.d.ts +5 -0
- package/dist/env.d.ts +2 -0
- package/dist/livedata.d.ts +14 -0
- package/dist/oidc.d.ts +12 -0
- package/dist/prompts/system.md +2 -1
- package/dist/requestScope.d.ts +10 -2
- package/dist/search-IBZ2BK2E.js +12 -0
- package/dist/worker_mcp/src/index.js +2 -2
- package/dist/worker_public/src/config.js +1 -1
- package/dist/worker_public/src/index.js +8 -8
- package/dist/worker_public/src/requestScope.js +2 -2
- package/docs/API.md +83 -0
- package/package.json +1 -1
- package/workers/shared/session.ts +10 -0
- package/workers/worker_public/prompts/system.md +2 -1
- package/workers/worker_public/src/apicalls.ts +447 -0
- package/workers/worker_public/src/ask.ts +58 -8
- package/workers/worker_public/src/auth.ts +7 -1
- package/workers/worker_public/src/config.ts +4 -0
- package/workers/worker_public/src/context.ts +89 -4
- package/workers/worker_public/src/delegated.ts +83 -0
- package/workers/worker_public/src/drafts.ts +10 -0
- package/workers/worker_public/src/env.ts +5 -1
- package/workers/worker_public/src/index.ts +4 -1
- package/workers/worker_public/src/livedata.ts +24 -2
- package/workers/worker_public/src/oidc.ts +35 -19
- package/workers/worker_public/src/ports/cloudflare/adapters.ts +8 -0
- package/workers/worker_public/src/requestScope.ts +9 -3
- package/dist/ask-GU554Y45.js +0 -12
- package/dist/search-LGDQR43R.js +0 -12
|
@@ -12,6 +12,30 @@
|
|
|
12
12
|
// answer depends on the declaration, not just the query) and is never
|
|
13
13
|
// written into them — the caches stay context-clean by construction.
|
|
14
14
|
|
|
15
|
+
/** One act the declared entity's lifecycle machine offers at its current
|
|
16
|
+
* state, FOR THE DECLARING USER'S ROLE (the platform filters before
|
|
17
|
+
* publishing; the wire is advisory — the platform re-validates every
|
|
18
|
+
* proposed act authoritatively on the way back). */
|
|
19
|
+
export interface MachineAct {
|
|
20
|
+
/** the machine's own action id (the model's vocabulary) */
|
|
21
|
+
action: string;
|
|
22
|
+
/** the state the act lands the entity on */
|
|
23
|
+
to: string;
|
|
24
|
+
/** the declared guard (the caller-supplied computed input the
|
|
25
|
+
* transition requires), when the transition declares one */
|
|
26
|
+
guard?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The affordance channel (TODO.ai-platform/08): the entity declaration's
|
|
30
|
+
* machine facet — the lifecycle machine's current state (the fact,
|
|
31
|
+
* published even when the role fires nothing) + the acts offered to the
|
|
32
|
+
* declaring user's role (EMPTY means "the user can read but not act").
|
|
33
|
+
* Only ever present on the entity kind. */
|
|
34
|
+
export interface MachineContext {
|
|
35
|
+
state: string;
|
|
36
|
+
acts: MachineAct[];
|
|
37
|
+
}
|
|
38
|
+
|
|
15
39
|
export interface DeclaredContext {
|
|
16
40
|
kind: "page" | "entity" | "document" | "account";
|
|
17
41
|
/** display label ("this certificate R60/2021-A-EX1-26.01") — echoed
|
|
@@ -24,6 +48,8 @@ export interface DeclaredContext {
|
|
|
24
48
|
* (urn:oiml:pub:r:60-1:2021) or the plain docidentifier */
|
|
25
49
|
doc?: string;
|
|
26
50
|
edition?: string;
|
|
51
|
+
/** the affordance channel's machine facet (entity kind only) */
|
|
52
|
+
machine?: MachineContext;
|
|
27
53
|
}
|
|
28
54
|
|
|
29
55
|
/** The account context's live-read echo (TODO.ai-platform/03): WHEN the
|
|
@@ -55,10 +81,47 @@ export interface AppliedContext {
|
|
|
55
81
|
* chip grounded this answer in the model node itself — its constraint,
|
|
56
82
|
* its provenance, its tests */
|
|
57
83
|
model?: { node_id: string; kind: string; standard: string; clause?: string };
|
|
84
|
+
/** the affordance channel's echo (TODO.ai-platform/08): the declared
|
|
85
|
+
* machine's state + how many acts it offered the declaring user's role
|
|
86
|
+
* — the panel's honest context line reads THIS, never its own copy */
|
|
87
|
+
machine?: { state: string; offered: number };
|
|
58
88
|
}
|
|
59
89
|
|
|
60
90
|
export const NO_CONTEXT: AppliedContext = { kind: "none", scoped_to: null };
|
|
61
91
|
|
|
92
|
+
/** The affordance channel's bounds (TODO.ai-platform/08): the machine
|
|
93
|
+
* facet is a hint from the panel, so it parses bounded and degrades to
|
|
94
|
+
* undefined (no facet) on any malformation — never to an error. The
|
|
95
|
+
* state is the fact; the acts may be EMPTY (the role fires nothing at
|
|
96
|
+
* this state — the honest "read but not act"). */
|
|
97
|
+
const MACHINE_STATE_MAX = 60;
|
|
98
|
+
const MACHINE_ACT_MAX = 24;
|
|
99
|
+
const MACHINE_FIELD_MAX = 60;
|
|
100
|
+
|
|
101
|
+
export function parseMachine(v: any): MachineContext | undefined {
|
|
102
|
+
if (!v || typeof v !== "object") return undefined;
|
|
103
|
+
const state = typeof v.state === "string" ? v.state.trim().slice(0, MACHINE_STATE_MAX) : "";
|
|
104
|
+
if (!state) return undefined;
|
|
105
|
+
const acts: MachineAct[] = [];
|
|
106
|
+
for (const a of Array.isArray(v.acts) ? v.acts.slice(0, MACHINE_ACT_MAX) : []) {
|
|
107
|
+
const action = typeof a?.action === "string" ? a.action.trim().slice(0, MACHINE_FIELD_MAX) : "";
|
|
108
|
+
const to = typeof a?.to === "string" ? a.to.trim().slice(0, MACHINE_FIELD_MAX) : "";
|
|
109
|
+
if (!action || !to) continue;
|
|
110
|
+
const guard = typeof a?.guard === "string" && a.guard.trim() ? a.guard.trim().slice(0, MACHINE_FIELD_MAX) : undefined;
|
|
111
|
+
acts.push({ action, to, ...(guard ? { guard } : {}) });
|
|
112
|
+
}
|
|
113
|
+
return { state, acts };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The proposal bound (TODO.ai-platform/08): a draft act the offered set
|
|
117
|
+
* does not carry is never proposed. An absent facet is not a bound (the
|
|
118
|
+
* page publishes none); a present facet with an empty acts array bounds
|
|
119
|
+
* to NOTHING — the user can read but not act. */
|
|
120
|
+
export function machineOffers(machine: MachineContext | undefined, action: string): boolean {
|
|
121
|
+
if (!machine) return false;
|
|
122
|
+
return machine.acts.some((a) => a.action === action);
|
|
123
|
+
}
|
|
124
|
+
|
|
62
125
|
/** Parse + bound the ask body's optional `context` field. Anything
|
|
63
126
|
* malformed degrades to null (no context), never to a 400 — a context
|
|
64
127
|
* the service can't parse is a context it must not apply. */
|
|
@@ -70,7 +133,9 @@ export function parseContext(body: any): DeclaredContext | null {
|
|
|
70
133
|
const route = typeof c.route === "string" && c.route.trim() ? c.route.trim().slice(0, 200) : undefined;
|
|
71
134
|
const doc = typeof c.doc === "string" && c.doc.trim() ? c.doc.trim().slice(0, 80) : undefined;
|
|
72
135
|
const edition = typeof c.edition === "string" && /^\d{4}$/.test(c.edition.trim()) ? c.edition.trim() : undefined;
|
|
73
|
-
|
|
136
|
+
// the machine facet rides the entity kind only (the channel's contract)
|
|
137
|
+
const machine = c.kind === "entity" ? parseMachine(c.machine) : undefined;
|
|
138
|
+
return { kind: c.kind, label, ...(route ? { route } : {}), ...(doc ? { doc } : {}), ...(edition ? { edition } : {}), ...(machine ? { machine } : {}) };
|
|
74
139
|
}
|
|
75
140
|
|
|
76
141
|
import { refCodec, type DocScope } from "./codecs.ts";
|
|
@@ -132,6 +197,10 @@ export function appliedContext(
|
|
|
132
197
|
scoped_to: scope ? scope.label : null,
|
|
133
198
|
...(note ? { note } : {}),
|
|
134
199
|
...(live ? { live } : {}),
|
|
200
|
+
// the affordance echo (TODO.ai-platform/08): the machine's state +
|
|
201
|
+
// the offered-act COUNT (the acts themselves are the platform's
|
|
202
|
+
// vocabulary, echoed back only bounded on the draft path)
|
|
203
|
+
...(declared.machine ? { machine: { state: declared.machine.state, offered: declared.machine.acts.length } } : {}),
|
|
135
204
|
};
|
|
136
205
|
}
|
|
137
206
|
|
|
@@ -165,7 +234,14 @@ export function parseAppliedContext(v: any): AppliedContext | null {
|
|
|
165
234
|
...(typeof v.model.clause === "string" && v.model.clause.trim() ? { clause: v.model.clause.slice(0, 120) } : {}),
|
|
166
235
|
}
|
|
167
236
|
: undefined;
|
|
168
|
-
|
|
237
|
+
// The machine echo round-trips bounded too (TODO.ai-platform/08): a
|
|
238
|
+
// resumed session keeps the "the machine offers N acts at this state"
|
|
239
|
+
// honesty.
|
|
240
|
+
const machine =
|
|
241
|
+
v.machine && typeof v.machine === "object" && typeof v.machine.state === "string" && typeof v.machine.offered === "number"
|
|
242
|
+
? { state: v.machine.state.slice(0, MACHINE_STATE_MAX), offered: Math.min(Math.max(0, Math.floor(v.machine.offered)), MACHINE_ACT_MAX) }
|
|
243
|
+
: undefined;
|
|
244
|
+
return { kind: v.kind, ...(label ? { label } : {}), scoped_to: scoped, ...(note ? { note } : {}), ...(live ? { live } : {}), ...(model ? { model } : {}), ...(machine ? { machine } : {}) };
|
|
169
245
|
}
|
|
170
246
|
|
|
171
247
|
/** The prompt note the declared context contributes (rides the
|
|
@@ -185,9 +261,18 @@ export function contextNote(declared: DeclaredContext | null, scope: DocScope |
|
|
|
185
261
|
return `Context note: the user is viewing ${declared.label || "a page"}${declared.route ? ` (${declared.route})` : ""} in the ${P().publisher.product_name} platform. The passages come from the general corpus; frame procedural guidance for that page when relevant.`;
|
|
186
262
|
}
|
|
187
263
|
if (declared.kind === "entity") {
|
|
264
|
+
// The affordance channel's sentence (TODO.ai-platform/08): the
|
|
265
|
+
// machine's state is a fact the answer may use; the offered acts are
|
|
266
|
+
// the proposal bound — an act the set does not offer is never
|
|
267
|
+
// proposed, and an empty set is the honest "read but not act".
|
|
268
|
+
const machineSentence = declared.machine
|
|
269
|
+
? declared.machine.acts.length
|
|
270
|
+
? ` The entity's lifecycle machine is at ${declared.machine.state}; the acts it offers this user's role there are exactly: ${declared.machine.acts.map((a) => a.action).join(", ")}. Never propose an act outside that set.`
|
|
271
|
+
: ` The entity's lifecycle machine is at ${declared.machine.state}, and it offers this user's role NO acts there — the user can read but not act; say so when they ask for an act, and propose nothing.`
|
|
272
|
+
: "";
|
|
188
273
|
return scope
|
|
189
|
-
? `Context note: the user is asking about ${declared.label || "an entity"} — the passages are scoped to ${scope.label}, the publication that governs it. You do NOT have the entity's own data; answer what the publication requires and say when the question needs the record itself
|
|
190
|
-
: `Context note: the user is asking about ${declared.label || "an entity"}. You do NOT have the entity's own data; answer from the corpus passages and say when the question needs the record itself
|
|
274
|
+
? `Context note: the user is asking about ${declared.label || "an entity"} — the passages are scoped to ${scope.label}, the publication that governs it. You do NOT have the entity's own data; answer what the publication requires and say when the question needs the record itself.${machineSentence}`
|
|
275
|
+
: `Context note: the user is asking about ${declared.label || "an entity"}. You do NOT have the entity's own data; answer from the corpus passages and say when the question needs the record itself.${machineSentence}`;
|
|
191
276
|
}
|
|
192
277
|
return scope
|
|
193
278
|
? `Context note: the user scoped this question to ${scope.label} — the passages come from that publication. If they cannot answer the question, say so instead of drawing on other documents.`
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The delegated bearer (TODO.ai-platform/12 — the session bridge): the
|
|
2
|
+
// platform-embedded bubble rides the app's own OIDC session. The
|
|
3
|
+
// platform's same-origin proxy exchanges the retained OP access token
|
|
4
|
+
// (RFC 8693, the identity RP guide §9b) and forwards the ask with the
|
|
5
|
+
// OP-minted delegated JWT as the Bearer. This module admits THAT
|
|
6
|
+
// credential as the member principal:
|
|
7
|
+
//
|
|
8
|
+
// - verified against the OP's JWKS — signature, issuer, expiry
|
|
9
|
+
// (oidc.ts's verifier; discovery + JWKS ride the shared caches);
|
|
10
|
+
// - the delegation marker is REQUIRED: act.sub names the delegating
|
|
11
|
+
// surface (the platform instance's OP client), and the deployment
|
|
12
|
+
// declares the surfaces it trusts (publisher.identity.delegators) —
|
|
13
|
+
// a plain access token without the act claim is NOT a member
|
|
14
|
+
// credential here; the delegation is the contract;
|
|
15
|
+
// - the JWT's sub is the user; the standing re-judgment stays ours —
|
|
16
|
+
// the roles cone honored is THIS service's
|
|
17
|
+
// (service_roles[<our client id>] when the exchange scoped us in),
|
|
18
|
+
// never the delegator's.
|
|
19
|
+
//
|
|
20
|
+
// Every failure resolves to null — the anonymous tier, never an error
|
|
21
|
+
// page. The feature is profile-gated (publisher.features.delegated_bearer):
|
|
22
|
+
// the standalone site's own session bridge is untouched by default.
|
|
23
|
+
|
|
24
|
+
import { discoverIssuer, verifyJwtSignature } from "./oidc.ts";
|
|
25
|
+
import { P } from "./profile.ts";
|
|
26
|
+
import type { SessionClaims } from "./session.ts";
|
|
27
|
+
|
|
28
|
+
const EXPIRY_LEEWAY_MS = 60_000;
|
|
29
|
+
|
|
30
|
+
/** The delegated JWT's claim shape (the OP's delegationTokenClaims):
|
|
31
|
+
* sub the user, aud the scoped services, scope the granted cone,
|
|
32
|
+
* service_roles per service, act.sub the delegating client. */
|
|
33
|
+
interface DelegatedJwtClaims {
|
|
34
|
+
iss?: string;
|
|
35
|
+
sub?: string;
|
|
36
|
+
aud?: string | string[];
|
|
37
|
+
iat?: number;
|
|
38
|
+
exp?: number;
|
|
39
|
+
scope?: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
email?: string;
|
|
42
|
+
service_roles?: Record<string, unknown>;
|
|
43
|
+
act?: { sub?: unknown };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function delegatedBearerFrom(req: Request, env: any): Promise<SessionClaims | null> {
|
|
47
|
+
if (!P().publisher.features?.delegated_bearer) return null;
|
|
48
|
+
const issuer = (env.OIDC_ISSUER ?? "").trim();
|
|
49
|
+
if (!issuer) return null;
|
|
50
|
+
const m = (req.headers.get("authorization") ?? "").match(/^Bearer\s+(.+)$/i);
|
|
51
|
+
if (!m) return null;
|
|
52
|
+
const token = m[1].trim();
|
|
53
|
+
// the service session is a two-segment HMAC token; three segments mark
|
|
54
|
+
// the JWT candidate (anything else never reaches the verifier)
|
|
55
|
+
if (token.split(".").length !== 3) return null;
|
|
56
|
+
// fail closed: a deployment opting in names its delegating surfaces
|
|
57
|
+
const delegators = P().publisher.identity?.delegators;
|
|
58
|
+
if (!Array.isArray(delegators) || delegators.length === 0) return null;
|
|
59
|
+
try {
|
|
60
|
+
const meta = await discoverIssuer(issuer);
|
|
61
|
+
const { claims } = await verifyJwtSignature(token, meta.jwks_uri);
|
|
62
|
+
const c = claims as DelegatedJwtClaims;
|
|
63
|
+
if (c.iss?.replace(/\/$/, "") !== meta.issuer.replace(/\/$/, "")) return null;
|
|
64
|
+
if (typeof c.exp !== "number" || c.exp * 1000 + EXPIRY_LEEWAY_MS < Date.now()) return null;
|
|
65
|
+
const actSub = typeof c.act?.sub === "string" ? c.act.sub : null;
|
|
66
|
+
if (!actSub || !delegators.includes(actSub)) return null;
|
|
67
|
+
if (typeof c.sub !== "string" || !c.sub) return null;
|
|
68
|
+
const ourRoles = c.service_roles?.[env.OIDC_CLIENT_ID];
|
|
69
|
+
return {
|
|
70
|
+
sub: c.sub,
|
|
71
|
+
name: typeof c.name === "string" ? c.name : undefined,
|
|
72
|
+
email: typeof c.email === "string" ? c.email : undefined,
|
|
73
|
+
roles: Array.isArray(ourRoles) ? ourRoles.filter((r): r is string => typeof r === "string") : [],
|
|
74
|
+
iat: typeof c.iat === "number" ? c.iat * 1000 : Date.now(),
|
|
75
|
+
exp: c.exp * 1000,
|
|
76
|
+
scope: typeof c.scope === "string" ? c.scope : undefined,
|
|
77
|
+
via: "delegated",
|
|
78
|
+
delegator: actSub,
|
|
79
|
+
};
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -231,6 +231,16 @@ const norm = (s: string) =>
|
|
|
231
231
|
/** The document-name normalization: whitespace-free ("R 60" ≡ "R60"). */
|
|
232
232
|
const docNorm = (s: string) => norm(s).replace(/\s+/g, "");
|
|
233
233
|
|
|
234
|
+
/** The traceability primitive, shared with the api_call act's body guard
|
|
235
|
+
* (apicalls.ts): a value traces when it appears in the user's own
|
|
236
|
+
* messages (normalized). The proposing model never gets the benefit of
|
|
237
|
+
* the doubt — the user's words are the only source. */
|
|
238
|
+
export function valueTracesToUser(value: unknown, userTurns: string[]): boolean {
|
|
239
|
+
if (typeof value !== "string" || !value.trim()) return false;
|
|
240
|
+
const haystack = norm(userTurns.join("\n"));
|
|
241
|
+
return haystack.includes(norm(value));
|
|
242
|
+
}
|
|
243
|
+
|
|
234
244
|
export interface GuardedFields {
|
|
235
245
|
standard?: string;
|
|
236
246
|
family_designation?: string;
|
|
@@ -48,12 +48,13 @@ export interface Env {
|
|
|
48
48
|
// Domain modules import `portModelRunner(env)` etc. — never env.AI
|
|
49
49
|
// directly; the purity lint enforces it. Zero runtime change: the
|
|
50
50
|
// adapters forward to the same bindings the call sites used before.
|
|
51
|
-
import { cfModelRunner, cfVectorIndex, cfKv, cfBlobs, cfRuntime } from "./ports/cloudflare/adapters.ts";
|
|
51
|
+
import { cfModelRunner, cfVectorIndex, cfKv, cfBlobs, cfRuntime, cfStore } from "./ports/cloudflare/adapters.ts";
|
|
52
52
|
import type { ModelRunner } from "./ports/model.ts";
|
|
53
53
|
import type { VectorIndex } from "./ports/vector.ts";
|
|
54
54
|
import type { Kv } from "./ports/kv.ts";
|
|
55
55
|
import type { Blobs } from "./ports/blobs.ts";
|
|
56
56
|
import type { Runtime } from "./ports/runtime.ts";
|
|
57
|
+
import type { StoreQuery } from "./ports/store.ts";
|
|
57
58
|
|
|
58
59
|
export function portModelRunner(env: Env): ModelRunner {
|
|
59
60
|
return cfModelRunner(env.AI);
|
|
@@ -76,6 +77,9 @@ export function portKv(env: Env): Kv {
|
|
|
76
77
|
export function portBlobs(env: Env): Blobs {
|
|
77
78
|
return cfBlobs(env.UNIT_ASSETS);
|
|
78
79
|
}
|
|
80
|
+
export function portStore(env: Env): StoreQuery {
|
|
81
|
+
return cfStore(env.DB);
|
|
82
|
+
}
|
|
79
83
|
/** Does the deployment bind this lane index? Presence wiring stays in
|
|
80
84
|
* the ports layer so domain stages never touch raw bindings. */
|
|
81
85
|
export function hasLane(env: Env, which: "glossary"): boolean {
|
|
@@ -125,7 +125,10 @@ async function tierFor(c: RouteContext): Promise<{ tier: "anon" | "key" | "membe
|
|
|
125
125
|
if (!key) return err(401, "unauthorized", `Provide a valid API key: Authorization: Bearer ${P().publisher.id}_...`);
|
|
126
126
|
}
|
|
127
127
|
let tier: "anon" | "key" | "member" = isApi ? "key" : "anon";
|
|
128
|
-
|
|
128
|
+
// No SESSION_SECRET precondition: the delegated bearer (the platform
|
|
129
|
+
// bubble's OP-minted JWT, delegated.ts) admits the member lane on its
|
|
130
|
+
// own — sessionFrom returns null honestly when neither lane applies.
|
|
131
|
+
if (!isApi && (await sessionFrom(c.req, c.env as any))) tier = "member";
|
|
129
132
|
return { tier, key };
|
|
130
133
|
}
|
|
131
134
|
|
|
@@ -186,6 +186,28 @@ export async function exchangeForLiveToken(env: any, sessionRaw: string): Promis
|
|
|
186
186
|
return { ok: true, token: granted.access_token };
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/** The live read token for one ask (TODO.ai-platform/12 — the session
|
|
190
|
+
* bridge): when the member arrived on the delegated bearer, the Bearer
|
|
191
|
+
* IS already the OP-minted platform-scoped JWT — the platform exchanged
|
|
192
|
+
* it before forwarding, so a second exchange here would have no subject
|
|
193
|
+
* token to ride (the bubble never signed into THIS service). If the
|
|
194
|
+
* token's scope cone covers the platform read, present it directly;
|
|
195
|
+
* otherwise (a service session, or a delegation that never scoped the
|
|
196
|
+
* platform in) fall through to the RFC 8693 exchange. */
|
|
197
|
+
export async function liveTokenFor(
|
|
198
|
+
env: any,
|
|
199
|
+
sessionRaw: string,
|
|
200
|
+
member: { via?: string; scope?: string } | null,
|
|
201
|
+
): Promise<LiveTokenVerdict> {
|
|
202
|
+
const cfg = liveDataConfig(env);
|
|
203
|
+
if (!cfg) return { ok: false, reason: "not_configured" };
|
|
204
|
+
if (member?.via === "delegated" && typeof member.scope === "string") {
|
|
205
|
+
const need = `${cfg.platformClientId}:read`;
|
|
206
|
+
if (member.scope.split(/\s+/).includes(need)) return { ok: true, token: sessionRaw };
|
|
207
|
+
}
|
|
208
|
+
return exchangeForLiveToken(env, sessionRaw);
|
|
209
|
+
}
|
|
210
|
+
|
|
189
211
|
// ── the platform reads (the records the answer grounds in) ───────────
|
|
190
212
|
|
|
191
213
|
export interface LiveRecord {
|
|
@@ -357,12 +379,12 @@ export type LiveAccount =
|
|
|
357
379
|
export async function resolveLiveAccount(
|
|
358
380
|
env: any,
|
|
359
381
|
sessionRaw: string | null,
|
|
360
|
-
member: { sub: string } | null,
|
|
382
|
+
member: { sub: string; via?: string; scope?: string } | null,
|
|
361
383
|
): Promise<LiveAccount> {
|
|
362
384
|
if (!member || !sessionRaw) return { status: "unavailable", reason: "sign_in_required" };
|
|
363
385
|
const cfg = liveDataConfig(env);
|
|
364
386
|
if (!cfg) return { status: "unavailable", reason: "not_configured" };
|
|
365
|
-
const exchanged = await
|
|
387
|
+
const exchanged = await liveTokenFor(env, sessionRaw, member);
|
|
366
388
|
if (!exchanged.ok) return { status: "unavailable", reason: exchanged.reason };
|
|
367
389
|
const read = await readMyAccount(env, cfg, exchanged.token);
|
|
368
390
|
if (!read.ok) return { status: "unavailable", reason: read.reason };
|
|
@@ -18,9 +18,11 @@ export type OidcFailureReason =
|
|
|
18
18
|
| 'token_nonce';
|
|
19
19
|
|
|
20
20
|
export class OidcError extends Error {
|
|
21
|
-
|
|
21
|
+
readonly reason: OidcFailureReason;
|
|
22
|
+
constructor(reason: OidcFailureReason, message: string) {
|
|
22
23
|
super(message);
|
|
23
24
|
this.name = "OidcError";
|
|
25
|
+
this.reason = reason;
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -252,31 +254,36 @@ export interface OidcIdTokenClaims {
|
|
|
252
254
|
|
|
253
255
|
const EXPIRY_LEEWAY_MS = 60_000;
|
|
254
256
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
257
|
+
/** Decode + signature-verify a JWT against the issuer's JWKS (RS256 or
|
|
258
|
+
* ES256; the JWKS fetch caches an hour and force-refreshes once on a
|
|
259
|
+
* miss). Claim semantics (issuer, audience, expiry, nonce) are the
|
|
260
|
+
* CALLER's — the ID token and the delegated bearer check different
|
|
261
|
+
* claim sets over the same verified signature. */
|
|
262
|
+
export async function verifyJwtSignature(
|
|
263
|
+
token: string,
|
|
264
|
+
jwksUri: string,
|
|
265
|
+
): Promise<{ header: { alg?: string; kid?: string }; claims: Record<string, unknown> }> {
|
|
266
|
+
const parts = token.split(".");
|
|
260
267
|
if (parts.length !== 3) {
|
|
261
|
-
throw new OidcError("token_malformed", "the
|
|
268
|
+
throw new OidcError("token_malformed", "the token is not a three-part JWT");
|
|
262
269
|
}
|
|
263
270
|
let header: { alg?: string; kid?: string };
|
|
264
|
-
let claims:
|
|
271
|
+
let claims: Record<string, unknown>;
|
|
265
272
|
try {
|
|
266
273
|
header = JSON.parse(new TextDecoder().decode(base64urlDecode(parts[0])));
|
|
267
|
-
claims = JSON.parse(new TextDecoder().decode(base64urlDecode(parts[1]))) as
|
|
274
|
+
claims = JSON.parse(new TextDecoder().decode(base64urlDecode(parts[1]))) as Record<string, unknown>;
|
|
268
275
|
} catch {
|
|
269
|
-
throw new OidcError("token_malformed", "the
|
|
276
|
+
throw new OidcError("token_malformed", "the token header/claims are not JSON");
|
|
270
277
|
}
|
|
271
278
|
if (header.alg !== "RS256" && header.alg !== "ES256") {
|
|
272
|
-
throw new OidcError("token_alg", `the
|
|
279
|
+
throw new OidcError("token_alg", `the token uses ${header.alg ?? "no declared algorithm"}`);
|
|
273
280
|
}
|
|
274
281
|
|
|
275
282
|
const signedContent = new TextEncoder().encode(`${parts[0]}.${parts[1]}`);
|
|
276
283
|
const signature = base64urlDecode(parts[2]) as BufferSource;
|
|
277
284
|
let verified = false;
|
|
278
285
|
for (const force of [false, true]) {
|
|
279
|
-
const keys = await fetchJwks(
|
|
286
|
+
const keys = await fetchJwks(jwksUri, force);
|
|
280
287
|
const candidates = keys.filter(
|
|
281
288
|
(k) => (!header.kid || k.kid === header.kid) && (header.alg === "RS256" ? k.kty === "RSA" : k.kty === "EC"),
|
|
282
289
|
);
|
|
@@ -303,26 +310,35 @@ export async function validateIdToken(
|
|
|
303
310
|
if (verified) break;
|
|
304
311
|
}
|
|
305
312
|
if (!verified) {
|
|
306
|
-
throw new OidcError("token_signature", "the
|
|
313
|
+
throw new OidcError("token_signature", "the token signature does not verify against the issuer's published keys");
|
|
307
314
|
}
|
|
315
|
+
return { header, claims };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function validateIdToken(
|
|
319
|
+
idToken: string,
|
|
320
|
+
expectations: { issuer: string; clientId: string; nonce: string; jwksUri: string },
|
|
321
|
+
): Promise<OidcIdTokenClaims> {
|
|
322
|
+
const { claims } = await verifyJwtSignature(idToken, expectations.jwksUri);
|
|
323
|
+
const idClaims = claims as OidcIdTokenClaims;
|
|
308
324
|
|
|
309
|
-
if (
|
|
325
|
+
if (idClaims.iss?.replace(/\/$/, "") !== expectations.issuer.replace(/\/$/, "")) {
|
|
310
326
|
throw new OidcError("token_issuer", "the ID token's issuer is not the configured issuer");
|
|
311
327
|
}
|
|
312
|
-
const audiences = Array.isArray(
|
|
328
|
+
const audiences = Array.isArray(idClaims.aud) ? idClaims.aud : [idClaims.aud];
|
|
313
329
|
if (!audiences.includes(expectations.clientId)) {
|
|
314
330
|
throw new OidcError("token_audience", "the ID token was not issued for this application");
|
|
315
331
|
}
|
|
316
|
-
if (audiences.length > 1 &&
|
|
332
|
+
if (audiences.length > 1 && idClaims.azp && idClaims.azp !== expectations.clientId) {
|
|
317
333
|
throw new OidcError("token_audience", "the ID token's authorized party is not this application");
|
|
318
334
|
}
|
|
319
|
-
if (typeof
|
|
335
|
+
if (typeof idClaims.exp !== "number" || idClaims.exp * 1000 + EXPIRY_LEEWAY_MS < Date.now()) {
|
|
320
336
|
throw new OidcError("token_expired", "the ID token has expired");
|
|
321
337
|
}
|
|
322
|
-
if (
|
|
338
|
+
if (idClaims.nonce !== expectations.nonce) {
|
|
323
339
|
throw new OidcError("token_nonce", "the ID token's nonce does not match the request");
|
|
324
340
|
}
|
|
325
|
-
return
|
|
341
|
+
return idClaims;
|
|
326
342
|
}
|
|
327
343
|
|
|
328
344
|
export function buildEndSessionUrl(
|
|
@@ -167,6 +167,14 @@ export function cfBlobs(bucket: unknown): Blobs {
|
|
|
167
167
|
};
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// The transitional relational port (ports/store.ts): D1 IS the
|
|
171
|
+
// adapter — the port narrows to semantic repositories when the
|
|
172
|
+
// repository extraction completes.
|
|
173
|
+
import type { StoreQuery } from "../store.ts";
|
|
174
|
+
export function cfStore(db: unknown): StoreQuery {
|
|
175
|
+
return db as StoreQuery;
|
|
176
|
+
}
|
|
177
|
+
|
|
170
178
|
export function cfRuntime(ctx: { waitUntil(p: Promise<unknown>): void } | undefined): Runtime {
|
|
171
179
|
return {
|
|
172
180
|
defer(fn) {
|
|
@@ -56,8 +56,14 @@ export function entitlementScope(keys: Set<string>): Set<string> | null {
|
|
|
56
56
|
/** Validate + intersect. Returns { error } when the request explicitly
|
|
57
57
|
* disables every dataset (a user error, not a scope). The corpora a
|
|
58
58
|
* dataset searches travel WITH the declaration (profile datasets.yaml,
|
|
59
|
-
* `corpora:`) — the engine maps no publisher names.
|
|
60
|
-
|
|
59
|
+
* `corpora:`) — the engine maps no publisher names.
|
|
60
|
+
*
|
|
61
|
+
* `opts.keyWithEntitlements` (the key-tier licensed federation): an
|
|
62
|
+
* API-key caller whose request carries a VALIDATED entitlement set is
|
|
63
|
+
* admitted to the session-gated (federated) datasets — the key's
|
|
64
|
+
* entitlement set is the licensed scope the internal lane honors; the
|
|
65
|
+
* internal worker re-validates the key server-side before it answers. */
|
|
66
|
+
export function resolveRequestScope(body: any, member: unknown, opts?: { keyWithEntitlements?: boolean }): RequestScope | { error: "empty-datasets" } {
|
|
61
67
|
const allIds = DATASETS().map((d) => d.id);
|
|
62
68
|
const requested = Array.isArray(body?.datasets)
|
|
63
69
|
? (body.datasets as unknown[]).filter((x): x is string => typeof x === "string" && allIds.includes(x))
|
|
@@ -65,7 +71,7 @@ export function resolveRequestScope(body: any, member: unknown): RequestScope |
|
|
|
65
71
|
if (requested !== null && requested.length === 0) return { error: "empty-datasets" };
|
|
66
72
|
const permittedIds = allIds.filter((id) => {
|
|
67
73
|
const d = DATASETS().find((x) => x.id === id)!;
|
|
68
|
-
return d.session ? datasetAllowed(d, member) : true;
|
|
74
|
+
return d.session ? datasetAllowed(d, member) || !!opts?.keyWithEntitlements : true;
|
|
69
75
|
});
|
|
70
76
|
// requested ∩ permitted — an explicit request NEVER widens past the
|
|
71
77
|
// session's permissions (a member without ai-preview asking
|
package/dist/ask-GU554Y45.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
handleAsk
|
|
3
|
-
} from "./chunk-WSLADX7O.js";
|
|
4
|
-
import "./chunk-NHHQAGBW.js";
|
|
5
|
-
import "./chunk-2RKYO3OC.js";
|
|
6
|
-
import "./chunk-A3QHHUN5.js";
|
|
7
|
-
import "./chunk-JGKSUSF5.js";
|
|
8
|
-
import "./chunk-OMXAE27N.js";
|
|
9
|
-
import "./chunk-3FYJM7LH.js";
|
|
10
|
-
export {
|
|
11
|
-
handleAsk
|
|
12
|
-
};
|
package/dist/search-LGDQR43R.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
handleSearch
|
|
3
|
-
} from "./chunk-5LSEBH3J.js";
|
|
4
|
-
import "./chunk-NHHQAGBW.js";
|
|
5
|
-
import "./chunk-2RKYO3OC.js";
|
|
6
|
-
import "./chunk-A3QHHUN5.js";
|
|
7
|
-
import "./chunk-JGKSUSF5.js";
|
|
8
|
-
import "./chunk-OMXAE27N.js";
|
|
9
|
-
import "./chunk-3FYJM7LH.js";
|
|
10
|
-
export {
|
|
11
|
-
handleSearch
|
|
12
|
-
};
|