@konneal/engine 0.2.35 → 0.2.37

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.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  handleAsk
3
- } from "./chunk-YSWKFXXG.js";
4
- import "./chunk-GHDRKT4K.js";
3
+ } from "./chunk-SEOQWW77.js";
4
+ import "./chunk-37FSCXBQ.js";
5
5
  import "./chunk-R2V3X6SQ.js";
6
6
  import "./chunk-A3QHHUN5.js";
7
7
  import "./chunk-4GJGBGJK.js";
@@ -23803,6 +23803,55 @@ async function resolveLiveAccount(env, sessionRaw, member) {
23803
23803
  if (!read.ok) return { status: "unavailable", reason: read.reason };
23804
23804
  return { status: "ok", records: read.records, stores: read.stores, readAt: read.readAt };
23805
23805
  }
23806
+ function deviceClientIds(env) {
23807
+ return String(env.OIDC_DEVICE_CLIENT_IDS ?? "").split(/[\s,]+/).filter(Boolean);
23808
+ }
23809
+ function opCfg(env) {
23810
+ return { issuer: String(env.OIDC_ISSUER ?? "").trim().replace(/\/+$/, ""), clientId: String(env.OIDC_CLIENT_ID ?? "") };
23811
+ }
23812
+ function opMemberCanWrite(member) {
23813
+ if (!member) return false;
23814
+ if (member.via !== "op-token") return true;
23815
+ return /\bwrite\b/.test(member.scope ?? "");
23816
+ }
23817
+ function opMemberFromIntrospection(answer, ids) {
23818
+ if (!answer?.active) return null;
23819
+ const clientId = String(answer.client_id ?? "");
23820
+ if (!ids.includes(clientId)) return null;
23821
+ const sub = String(answer.sub ?? "");
23822
+ if (!sub) return null;
23823
+ return { sub, scope: String(answer.scope ?? ""), via: "op-token" };
23824
+ }
23825
+ async function opTokenMember(env, cfg, req) {
23826
+ const m = /^Bearer\s+(.+)$/i.exec((req.headers.get("Authorization") ?? "").trim());
23827
+ const token = m?.[1]?.trim();
23828
+ if (!token || token.split(".").length === 3) return null;
23829
+ const ids = deviceClientIds(env);
23830
+ if (!ids.length || !cfg.issuer || !cfg.clientId) return null;
23831
+ const key = `opint:${await sha256Hex2(token)}`;
23832
+ let answer = null;
23833
+ try {
23834
+ answer = await env.CACHE.get(key, "json");
23835
+ } catch {
23836
+ }
23837
+ if (answer === void 0 || answer === null) {
23838
+ try {
23839
+ const res = await fetch(`${cfg.issuer}/op/introspect`, {
23840
+ method: "POST",
23841
+ headers: { "content-type": "application/x-www-form-urlencoded" },
23842
+ body: new URLSearchParams({ token, client_id: cfg.clientId })
23843
+ });
23844
+ answer = res.ok ? await res.json() : { active: false };
23845
+ } catch {
23846
+ return null;
23847
+ }
23848
+ try {
23849
+ await env.CACHE.put(key, JSON.stringify(answer ?? { active: false }), { expirationTtl: 45 });
23850
+ } catch {
23851
+ }
23852
+ }
23853
+ return opMemberFromIntrospection(answer, ids);
23854
+ }
23806
23855
 
23807
23856
  // workers/worker_public/src/oidc.ts
23808
23857
  var OidcError = class extends Error {
@@ -24537,6 +24586,9 @@ export {
24537
24586
  liveDataConfig,
24538
24587
  liveTokenFor,
24539
24588
  resolveLiveAccount,
24589
+ opCfg,
24590
+ opMemberCanWrite,
24591
+ opTokenMember,
24540
24592
  handleLogin,
24541
24593
  handleCallback,
24542
24594
  sessionFrom,
@@ -16,6 +16,8 @@ import {
16
16
  liveTokenFor,
17
17
  machineOffers,
18
18
  namedDocumentIn,
19
+ opCfg,
20
+ opTokenMember,
19
21
  parseContext,
20
22
  portModelRunner,
21
23
  portStore,
@@ -30,7 +32,7 @@ import {
30
32
  syntheticUnderstanding,
31
33
  telemetry,
32
34
  understandQuery
33
- } from "./chunk-GHDRKT4K.js";
35
+ } from "./chunk-37FSCXBQ.js";
34
36
  import {
35
37
  corsHeaders,
36
38
  err,
@@ -1895,7 +1897,16 @@ async function handleAsk(env, ctx, req, tier, key) {
1895
1897
  const declaredCtx = parseContext(body);
1896
1898
  const draftAct = P().publisher.features?.drafts ? detectDraftIntent(q.query) : null;
1897
1899
  const apiCallIntent = !draftAct && P().publisher.features?.api_call_drafts ? detectApiCallIntent(q.query, declaredCtx) : null;
1898
- const member = tier === "member" ? await sessionFrom(req, env) : null;
1900
+ const session = tier === "member" ? await sessionFrom(req, env) : null;
1901
+ const opMember = !session && tier === "member" ? await opTokenMember(env, opCfg(env), req) : null;
1902
+ const member = session ?? (opMember ? {
1903
+ sub: opMember.sub,
1904
+ roles: [],
1905
+ iat: Math.floor(Date.now() / 1e3),
1906
+ exp: Math.floor(Date.now() / 1e3) + 3600,
1907
+ via: "op-token",
1908
+ scope: opMember.scope
1909
+ } : null);
1899
1910
  const effort = requestEffort(env, member, body?.effort);
1900
1911
  const limit = tier === "key" ? key.day_limit : tier === "member" || member ? num(env, "MEMBER_DAY_ASK", 300) : num(env, "ANON_DAY_ASK", 20);
1901
1912
  const bucketId = tier === "key" ? `key:${key.id}` : member ? `sub:${member.sub}` : clientIp(req);
@@ -7,7 +7,7 @@ import {
7
7
  sessionFrom,
8
8
  telemetry,
9
9
  understandQuery
10
- } from "./chunk-GHDRKT4K.js";
10
+ } from "./chunk-37FSCXBQ.js";
11
11
  import {
12
12
  corsHeaders,
13
13
  err,
package/dist/env.d.ts CHANGED
@@ -31,6 +31,11 @@ export interface Env {
31
31
  OIDC_CLIENT_ID?: string;
32
32
  OIDC_CLIENT_SECRET?: string;
33
33
  OIDC_REDIRECT_URI?: string;
34
+ /** TODO.ai-platform/14 — the Ommisa tier: the OP device-grant client
35
+ * ids (comma/space separated) whose opaque access tokens this
36
+ * deployment admits as member-read credentials via introspection.
37
+ * Empty/absent = the tier is off. */
38
+ OIDC_DEVICE_CLIENT_IDS?: string;
34
39
  SESSION_SECRET?: string;
35
40
  /** TODO.ai-platform/03 — the "my account" live-data delegation: the
36
41
  * platform instance's API base + its client id at the OP (the
@@ -88,3 +88,39 @@ export declare function resolveLiveAccount(env: any, sessionRaw: string | null,
88
88
  via?: string;
89
89
  scope?: string;
90
90
  } | null): Promise<LiveAccount>;
91
+ /** The device-grant clients this deployment admits. Empty = the tier is
92
+ * off (a Bearer token that is not a service session stays anonymous). */
93
+ export declare function deviceClientIds(env: any): string[];
94
+ /** The introspection call's OP coordinates, read once per call site. */
95
+ export declare function opCfg(env: any): {
96
+ issuer: string;
97
+ clientId: string;
98
+ };
99
+ export interface OpMember {
100
+ sub: string;
101
+ scope: string;
102
+ via: "op-token";
103
+ }
104
+ /** Mutations through the device-grant bearer (memories, conversations,
105
+ * projects — the member's OWN data) need the token's scope to state a
106
+ * write. The grammar is the OP's PAT vocabulary (`<service>:write`);
107
+ * a read-only approval stays read-only here, and service sessions
108
+ * (the browser) are always full-strength. */
109
+ export declare function opMemberCanWrite(member: {
110
+ via?: string;
111
+ scope?: string;
112
+ } | null): boolean;
113
+ /** The introspection answer → the member credential, judged against the
114
+ * deployment's allowlist. Inactive, foreign-client or subject-less
115
+ * answers all resolve to null — never to a widened guess. */
116
+ export declare function opMemberFromIntrospection(answer: any, ids: string[]): OpMember | null;
117
+ /** The Bearer read + introspection, KV-cached 45s keyed by the token's
118
+ * hash (an ask burst costs one introspection; a deactivation lands
119
+ * within a minute). The cached object is the OP's RAW answer — the
120
+ * allowlist re-judges on every read, so a config change takes effect
121
+ * inside the cache window too. JWTs (3 dot-segments) are NOT opaque:
122
+ * they belong to the delegated lane and never reach the OP here. */
123
+ export declare function opTokenMember(env: any, cfg: {
124
+ issuer: string;
125
+ clientId: string;
126
+ }, req: Request): Promise<OpMember | null>;
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  handleSearch
3
- } from "./chunk-Z7AQEW6G.js";
4
- import "./chunk-GHDRKT4K.js";
3
+ } from "./chunk-YEAAGFHB.js";
4
+ import "./chunk-37FSCXBQ.js";
5
5
  import "./chunk-R2V3X6SQ.js";
6
6
  import "./chunk-A3QHHUN5.js";
7
7
  import "./chunk-4GJGBGJK.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  handleSearch
3
- } from "../../chunk-Z7AQEW6G.js";
3
+ } from "../../chunk-YEAAGFHB.js";
4
4
  import {
5
5
  bindModelNode,
6
6
  checkQuoteAnchors,
@@ -9,7 +9,7 @@ import {
9
9
  modelGroundingBlock,
10
10
  scoreJudge,
11
11
  standardForDocNumber
12
- } from "../../chunk-YSWKFXXG.js";
12
+ } from "../../chunk-SEOQWW77.js";
13
13
  import {
14
14
  buildMessages,
15
15
  citations,
@@ -24,6 +24,9 @@ import {
24
24
  handleLogout,
25
25
  handleMe,
26
26
  namedDocumentIn,
27
+ opCfg,
28
+ opMemberCanWrite,
29
+ opTokenMember,
27
30
  parseAppliedContext,
28
31
  portModelRunner,
29
32
  promptVars,
@@ -32,7 +35,7 @@ import {
32
35
  sessionFrom,
33
36
  telemetry,
34
37
  understandQuery
35
- } from "../../chunk-GHDRKT4K.js";
38
+ } from "../../chunk-37FSCXBQ.js";
36
39
  import {
37
40
  authenticate,
38
41
  corsHeaders,
@@ -996,7 +999,7 @@ async function handleMcp(env, ctx, req, tier, key) {
996
999
  // stream:false forces the JSON lane (anon defaults to SSE)
997
1000
  body: JSON.stringify({ ...args, stream: false })
998
1001
  });
999
- const res = name === "ask" ? await (await import("../../ask-P6KLPBKL.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-IBZ2BK2E.js")).handleSearch(env, ctx, inner, tier, key);
1002
+ const res = name === "ask" ? await (await import("../../ask-J6NGXPOD.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-V6M4KASL.js")).handleSearch(env, ctx, inner, tier, key);
1000
1003
  return res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
1001
1004
  });
1002
1005
  if (out.ok && "accepted" in out) return new Response(null, { status: 202 });
@@ -1025,8 +1028,9 @@ function routeMatchesPath(pattern, path) {
1025
1028
  return params;
1026
1029
  }
1027
1030
  function matchRoute(routes, method, path) {
1031
+ const effective = method === "HEAD" ? "GET" : method;
1028
1032
  for (const route of routes) {
1029
- if (route.method !== method && route.method !== "*") continue;
1033
+ if (route.method !== effective && route.method !== "*") continue;
1030
1034
  const params = routeMatchesPath(route.pattern, path);
1031
1035
  if (params) return { route, params };
1032
1036
  }
@@ -1307,30 +1311,54 @@ async function serveIndexPage(c) {
1307
1311
  }
1308
1312
  return err(404, "not_found", "Page not found");
1309
1313
  }
1310
- async function memoriesRoute(c) {
1314
+ var MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
1315
+ async function opIdentity(c, signIn) {
1311
1316
  const session = await sessionFrom(c.req, c.env);
1312
- if (!session) return withCors(err(401, "unauthorized", "Sign in to use memory files"), corsHeaders(c.req));
1313
- return withCors(await handleMemories(c.env, session.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
1317
+ if (session) return { sub: session.sub, write: true };
1318
+ const member = await opTokenMember(c.env, opCfg(c.env), c.req);
1319
+ if (!member) return err(401, "unauthorized", signIn);
1320
+ return { sub: member.sub, write: opMemberCanWrite(member) };
1321
+ }
1322
+ function opWriteRefused(c, who) {
1323
+ if (!(who instanceof Response) && MUTATING_METHODS.has(c.req.method) && !who.write) {
1324
+ return err(403, "forbidden", "This sign-in grants read only \u2014 request a write scope at login.");
1325
+ }
1326
+ return null;
1327
+ }
1328
+ async function memoriesRoute(c) {
1329
+ const who = await opIdentity(c, "Sign in to use memory files");
1330
+ if (who instanceof Response) return withCors(who, corsHeaders(c.req));
1331
+ const refused = opWriteRefused(c, who);
1332
+ if (refused) return withCors(refused, corsHeaders(c.req));
1333
+ return withCors(await handleMemories(c.env, who.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
1314
1334
  }
1315
1335
  async function projectsRoute(c) {
1316
- const session = await sessionFrom(c.req, c.env);
1317
- if (!session) return withCors(err(401, "unauthorized", "Sign in to use projects"), corsHeaders(c.req));
1318
- return withCors(await handleProjects(c.env, session.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
1336
+ const who = await opIdentity(c, "Sign in to use projects");
1337
+ if (who instanceof Response) return withCors(who, corsHeaders(c.req));
1338
+ const refused = opWriteRefused(c, who);
1339
+ if (refused) return withCors(refused, corsHeaders(c.req));
1340
+ return withCors(await handleProjects(c.env, who.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
1319
1341
  }
1320
1342
  async function projectFilesRoute(c) {
1321
- const session = await sessionFrom(c.req, c.env);
1322
- if (!session) return withCors(err(401, "unauthorized", "Sign in to use projects"), corsHeaders(c.req));
1323
- return withCors(await handleProjectFiles(c.env, session.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
1343
+ const who = await opIdentity(c, "Sign in to use projects");
1344
+ if (who instanceof Response) return withCors(who, corsHeaders(c.req));
1345
+ const refused = opWriteRefused(c, who);
1346
+ if (refused) return withCors(refused, corsHeaders(c.req));
1347
+ return withCors(await handleProjectFiles(c.env, who.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
1324
1348
  }
1325
1349
  async function conversationsRoute(c) {
1326
- const session = await sessionFrom(c.req, c.env);
1327
- if (!session) return withCors(err(401, "unauthorized", "Sign in to sync your conversations across devices"), corsHeaders(c.req));
1328
- return withCors(await handleConversations(c.env, session.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
1350
+ const who = await opIdentity(c, "Sign in to sync your conversations across devices");
1351
+ if (who instanceof Response) return withCors(who, corsHeaders(c.req));
1352
+ const refused = opWriteRefused(c, who);
1353
+ if (refused) return withCors(refused, corsHeaders(c.req));
1354
+ return withCors(await handleConversations(c.env, who.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
1329
1355
  }
1330
1356
  async function appendMessageRoute(c) {
1331
- const session = await sessionFrom(c.req, c.env);
1332
- if (!session) return withCors(err(401, "unauthorized", "Sign in to sync your conversations across devices"), corsHeaders(c.req));
1333
- return withCors(await handleAppendMessage(c.env, session.sub, c.req, c.params.id), corsHeaders(c.req));
1357
+ const who = await opIdentity(c, "Sign in to sync your conversations across devices");
1358
+ if (who instanceof Response) return withCors(who, corsHeaders(c.req));
1359
+ const refused = opWriteRefused(c, who);
1360
+ if (refused) return withCors(refused, corsHeaders(c.req));
1361
+ return withCors(await handleAppendMessage(c.env, who.sub, c.req, c.params.id), corsHeaders(c.req));
1334
1362
  }
1335
1363
  async function shareRoute(c) {
1336
1364
  const session = await sessionFrom(c.req, c.env);
@@ -1360,6 +1388,7 @@ async function tierFor(c) {
1360
1388
  }
1361
1389
  let tier = isApi ? "key" : "anon";
1362
1390
  if (!isApi && await sessionFrom(c.req, c.env)) tier = "member";
1391
+ else if (!isApi && await opTokenMember(c.env, opCfg(c.env), c.req)) tier = "member";
1363
1392
  return { tier, key };
1364
1393
  }
1365
1394
  async function mcpRoute(c) {
package/package.json CHANGED
@@ -27,7 +27,7 @@
27
27
  "playwright": "^1.62.1",
28
28
  "yaml": "^2.9.1"
29
29
  },
30
- "version": "0.2.35",
30
+ "version": "0.2.37",
31
31
  "description": "The Konneal engine: the publisher-agnostic build pipeline and API plane for standards intelligence (retrieval, answer contract, verdicts, evaluation).",
32
32
  "license": "BSD-3-Clause",
33
33
  "type": "module",
@@ -43,8 +43,11 @@ export function routeMatchesPath(pattern: string, path: string): Record<string,
43
43
  }
44
44
 
45
45
  export function matchRoute(routes: Route[], method: string, path: string): { route: Route; params: Record<string, string> } | null {
46
+ // HEAD rides the GET route (the fetch pipeline strips the body) — a
47
+ // bare HEAD / must not answer 405 to link previewers and probes.
48
+ const effective = method === "HEAD" ? "GET" : method;
46
49
  for (const route of routes) {
47
- if (route.method !== method && route.method !== "*") continue;
50
+ if (route.method !== effective && route.method !== "*") continue;
48
51
  const params = routeMatchesPath(route.pattern, path);
49
52
  if (params) return { route, params };
50
53
  }
@@ -16,8 +16,9 @@ export interface SessionClaims {
16
16
  exp: number;
17
17
  /** the credential kind: absent = the service session; "delegated" =
18
18
  * the OP-minted RFC 8693 bearer (TODO.ai-platform/12 — the session
19
- * bridge; delegated.ts) */
20
- via?: "delegated";
19
+ * bridge; delegated.ts); "op-token" = the device-grant CLI's opaque
20
+ * bearer, admitted through RFC 7662 introspection (livedata.ts) */
21
+ via?: "delegated" | "op-token";
21
22
  /** the delegating surface (the delegated bearer's act.sub — the
22
23
  * platform instance's OP client) */
23
24
  delegator?: string;
@@ -19,7 +19,7 @@ import { canonicalRefusal } from "./refusal";
19
19
  import { contractV2, tableRetyped } from "./refs";
20
20
  import { completeTables, completeFigures } from "./completion";
21
21
  import { NO_CONTEXT, appliedContext, contextNote, namedDocumentIn, parseContext, resolveDocScope, syntheticUnderstanding } from "./context";
22
- import { liveDataConfig, liveTokenFor, resolveLiveAccount, type LiveRecord } from "./livedata";
22
+ import { liveDataConfig, liveTokenFor, opCfg, opTokenMember, resolveLiveAccount, type LiveRecord } from "./livedata";
23
23
  import { bindModelNode, licenseBoundaryNote, licenseBoundaryRefusal, licensedEntryForPackage, modelCitation, modelEcho, modelGroundingBlock, modelNodeRefIn, standardForDocNumber } from "./modelplane";
24
24
  import { evaluate as machineEvaluate, verdictNote } from "./verdict";
25
25
  import { evaluateConditionSets, quantitiesIn, type ConditionVerdict } from "./conditions";
@@ -29,7 +29,7 @@ import { detectDraftIntent, prepareDraft } from "./drafts";
29
29
  import { detectApiCallIntent, prepareApiCall } from "./apicalls";
30
30
  import { memoryNote } from "./memories";
31
31
  import { entitlementScope, resolveRequestScope, requestSalt, standardKeysFrom } from "./requestScope";
32
- import { rawSessionToken } from "./session";
32
+ import { rawSessionToken, type SessionClaims } from "./session";
33
33
  import { cacheKeyMaterial, corpusGen, exactCacheKey, freshRequested, semanticCacheKey } from "./answercache";
34
34
  import type { Env } from "./env";
35
35
  export type { Env };
@@ -286,7 +286,23 @@ async function handleAsk(
286
286
  // account's standing, never on the query alone.
287
287
  const apiCallIntent = !draftAct && P().publisher.features?.api_call_drafts ? detectApiCallIntent(q.query, declaredCtx) : null;
288
288
 
289
- const member = tier === "member" ? await sessionFrom(req, env as any) : null;
289
+ const session = tier === "member" ? await sessionFrom(req, env as any) : null;
290
+ // The Ommisa tier: no service session but a device-grant CLI's opaque
291
+ // bearer → the OP's introspection answer admits a member-READ
292
+ // credential (livedata.ts). No sessionRaw behind it, so the draft and
293
+ // live-data lanes keep their honest "unsigned" refusals.
294
+ const opMember = !session && tier === "member" ? await opTokenMember(env, opCfg(env), req) : null;
295
+ const member: SessionClaims | null = session
296
+ ?? (opMember
297
+ ? {
298
+ sub: opMember.sub,
299
+ roles: [],
300
+ iat: Math.floor(Date.now() / 1000),
301
+ exp: Math.floor(Date.now() / 1000) + 3600,
302
+ via: "op-token",
303
+ scope: opMember.scope,
304
+ }
305
+ : null);
290
306
  // resolved before the quota check: the effort choice prices the ask
291
307
  const effort = requestEffort(env, member, (body as any)?.effort);
292
308
 
@@ -33,6 +33,11 @@ export interface Env {
33
33
  OIDC_CLIENT_ID?: string;
34
34
  OIDC_CLIENT_SECRET?: string;
35
35
  OIDC_REDIRECT_URI?: string;
36
+ /** TODO.ai-platform/14 — the Ommisa tier: the OP device-grant client
37
+ * ids (comma/space separated) whose opaque access tokens this
38
+ * deployment admits as member-read credentials via introspection.
39
+ * Empty/absent = the tier is off. */
40
+ OIDC_DEVICE_CLIENT_IDS?: string;
36
41
  SESSION_SECRET?: string;
37
42
  /** TODO.ai-platform/03 — the "my account" live-data delegation: the
38
43
  * platform instance's API base + its client id at the OP (the
@@ -3,6 +3,7 @@ export { setProfile } from "./profile.ts";
3
3
  import { retrieve } from "./pipeline";
4
4
  import type { Hit } from "./pipeline";
5
5
  import { handleCallback, handleLogin, handleLogout, handleMe, sessionFrom } from "./auth";
6
+ import { opCfg, opMemberCanWrite, opTokenMember } from "./livedata";
6
7
  import { handleAppendMessage, handleConversations } from "./conversations";
7
8
  import { handleMemories } from "./memories";
8
9
  import { handleProjects, handleProjectFiles } from "./projects";
@@ -60,34 +61,63 @@ async function serveIndexPage(c: RouteContext): Promise<Response> {
60
61
  return err(404, "not_found", "Page not found");
61
62
  }
62
63
 
63
- async function memoriesRoute(c: RouteContext): Promise<Response> {
64
+ /** The member identity behind one member-data request: the service
65
+ * session first; else the Ommisa tier's introspected device-grant
66
+ * bearer (READ by default — mutations also need `opMemberCanWrite`).
67
+ * The CLI's memories/conversations/projects ride this. */
68
+ const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
69
+ async function opIdentity(c: RouteContext, signIn: string): Promise<{ sub: string; write: boolean } | Response> {
64
70
  const session = await sessionFrom(c.req, c.env as any);
65
- if (!session) return withCors(err(401, "unauthorized", "Sign in to use memory files"), corsHeaders(c.req));
66
- return withCors(await handleMemories(c.env, session.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
71
+ if (session) return { sub: session.sub, write: true };
72
+ const member = await opTokenMember(c.env, opCfg(c.env), c.req);
73
+ if (!member) return err(401, "unauthorized", signIn);
74
+ return { sub: member.sub, write: opMemberCanWrite(member) };
75
+ }
76
+ function opWriteRefused(c: RouteContext, who: { write: boolean } | Response): Response | null {
77
+ if (!(who instanceof Response) && MUTATING_METHODS.has(c.req.method) && !who.write) {
78
+ return err(403, "forbidden", "This sign-in grants read only — request a write scope at login.");
79
+ }
80
+ return null;
81
+ }
82
+
83
+ async function memoriesRoute(c: RouteContext): Promise<Response> {
84
+ const who = await opIdentity(c, "Sign in to use memory files");
85
+ if (who instanceof Response) return withCors(who, corsHeaders(c.req));
86
+ const refused = opWriteRefused(c, who);
87
+ if (refused) return withCors(refused, corsHeaders(c.req));
88
+ return withCors(await handleMemories(c.env, who.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
67
89
  }
68
90
 
69
91
  async function projectsRoute(c: RouteContext): Promise<Response> {
70
- const session = await sessionFrom(c.req, c.env as any);
71
- if (!session) return withCors(err(401, "unauthorized", "Sign in to use projects"), corsHeaders(c.req));
72
- return withCors(await handleProjects(c.env, session.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
92
+ const who = await opIdentity(c, "Sign in to use projects");
93
+ if (who instanceof Response) return withCors(who, corsHeaders(c.req));
94
+ const refused = opWriteRefused(c, who);
95
+ if (refused) return withCors(refused, corsHeaders(c.req));
96
+ return withCors(await handleProjects(c.env, who.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
73
97
  }
74
98
 
75
99
  async function projectFilesRoute(c: RouteContext): Promise<Response> {
76
- const session = await sessionFrom(c.req, c.env as any);
77
- if (!session) return withCors(err(401, "unauthorized", "Sign in to use projects"), corsHeaders(c.req));
78
- return withCors(await handleProjectFiles(c.env, session.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
100
+ const who = await opIdentity(c, "Sign in to use projects");
101
+ if (who instanceof Response) return withCors(who, corsHeaders(c.req));
102
+ const refused = opWriteRefused(c, who);
103
+ if (refused) return withCors(refused, corsHeaders(c.req));
104
+ return withCors(await handleProjectFiles(c.env, who.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
79
105
  }
80
106
 
81
107
  async function conversationsRoute(c: RouteContext): Promise<Response> {
82
- const session = await sessionFrom(c.req, c.env as any);
83
- if (!session) return withCors(err(401, "unauthorized", "Sign in to sync your conversations across devices"), corsHeaders(c.req));
84
- return withCors(await handleConversations(c.env, session.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
108
+ const who = await opIdentity(c, "Sign in to sync your conversations across devices");
109
+ if (who instanceof Response) return withCors(who, corsHeaders(c.req));
110
+ const refused = opWriteRefused(c, who);
111
+ if (refused) return withCors(refused, corsHeaders(c.req));
112
+ return withCors(await handleConversations(c.env, who.sub, c.req, { method: c.req.method, id: c.params.id }), corsHeaders(c.req));
85
113
  }
86
114
 
87
115
  async function appendMessageRoute(c: RouteContext): Promise<Response> {
88
- const session = await sessionFrom(c.req, c.env as any);
89
- if (!session) return withCors(err(401, "unauthorized", "Sign in to sync your conversations across devices"), corsHeaders(c.req));
90
- return withCors(await handleAppendMessage(c.env, session.sub, c.req, c.params.id!), corsHeaders(c.req));
116
+ const who = await opIdentity(c, "Sign in to sync your conversations across devices");
117
+ if (who instanceof Response) return withCors(who, corsHeaders(c.req));
118
+ const refused = opWriteRefused(c, who);
119
+ if (refused) return withCors(refused, corsHeaders(c.req));
120
+ return withCors(await handleAppendMessage(c.env, who.sub, c.req, c.params.id!), corsHeaders(c.req));
91
121
  }
92
122
 
93
123
  async function shareRoute(c: RouteContext): Promise<Response> {
@@ -128,7 +158,10 @@ async function tierFor(c: RouteContext): Promise<{ tier: "anon" | "key" | "membe
128
158
  // No SESSION_SECRET precondition: the delegated bearer (the platform
129
159
  // bubble's OP-minted JWT, delegated.ts) admits the member lane on its
130
160
  // own — sessionFrom returns null honestly when neither lane applies.
161
+ // The Ommisa tier rides the same admission: a device-grant CLI's
162
+ // opaque bearer, introspected at the OP (livedata.ts).
131
163
  if (!isApi && (await sessionFrom(c.req, c.env as any))) tier = "member";
164
+ else if (!isApi && (await opTokenMember(c.env, opCfg(c.env), c.req))) tier = "member";
132
165
  return { tier, key };
133
166
  }
134
167
 
@@ -390,3 +390,97 @@ export async function resolveLiveAccount(
390
390
  if (!read.ok) return { status: "unavailable", reason: read.reason };
391
391
  return { status: "ok", records: read.records, stores: read.stores, readAt: read.readAt };
392
392
  }
393
+
394
+ // ── the Ommisa tier (RFC 7662 introspection) ─────────────────────────
395
+ // The CLI signs in through the OP's device grant (RFC 8628) and holds an
396
+ // OPAQUE access token. This deployment never saw that grant — it learns
397
+ // the token the only honest way: it asks the OP (introspection, as an
398
+ // active registered client), then admits the caller as a member-READ
399
+ // credential. Writes, drafts and the live-data exchange stay session
400
+ // acts (the CLI has no service session to exchange — rawSessionToken is
401
+ // null, so those lanes already refuse honestly).
402
+
403
+ /** The device-grant clients this deployment admits. Empty = the tier is
404
+ * off (a Bearer token that is not a service session stays anonymous). */
405
+ export function deviceClientIds(env: any): string[] {
406
+ return String(env.OIDC_DEVICE_CLIENT_IDS ?? "")
407
+ .split(/[\s,]+/)
408
+ .filter(Boolean);
409
+ }
410
+
411
+ /** The introspection call's OP coordinates, read once per call site. */
412
+ export function opCfg(env: any): { issuer: string; clientId: string } {
413
+ return { issuer: String(env.OIDC_ISSUER ?? "").trim().replace(/\/+$/, ""), clientId: String(env.OIDC_CLIENT_ID ?? "") };
414
+ }
415
+
416
+ export interface OpMember {
417
+ sub: string;
418
+ scope: string;
419
+ via: "op-token";
420
+ }
421
+
422
+ /** Mutations through the device-grant bearer (memories, conversations,
423
+ * projects — the member's OWN data) need the token's scope to state a
424
+ * write. The grammar is the OP's PAT vocabulary (`<service>:write`);
425
+ * a read-only approval stays read-only here, and service sessions
426
+ * (the browser) are always full-strength. */
427
+ export function opMemberCanWrite(member: { via?: string; scope?: string } | null): boolean {
428
+ if (!member) return false;
429
+ if (member.via !== "op-token") return true;
430
+ return /\bwrite\b/.test(member.scope ?? "");
431
+ }
432
+
433
+ /** The introspection answer → the member credential, judged against the
434
+ * deployment's allowlist. Inactive, foreign-client or subject-less
435
+ * answers all resolve to null — never to a widened guess. */
436
+ export function opMemberFromIntrospection(answer: any, ids: string[]): OpMember | null {
437
+ if (!answer?.active) return null;
438
+ const clientId = String(answer.client_id ?? "");
439
+ if (!ids.includes(clientId)) return null;
440
+ const sub = String(answer.sub ?? "");
441
+ if (!sub) return null;
442
+ return { sub, scope: String(answer.scope ?? ""), via: "op-token" };
443
+ }
444
+
445
+ /** The Bearer read + introspection, KV-cached 45s keyed by the token's
446
+ * hash (an ask burst costs one introspection; a deactivation lands
447
+ * within a minute). The cached object is the OP's RAW answer — the
448
+ * allowlist re-judges on every read, so a config change takes effect
449
+ * inside the cache window too. JWTs (3 dot-segments) are NOT opaque:
450
+ * they belong to the delegated lane and never reach the OP here. */
451
+ export async function opTokenMember(
452
+ env: any,
453
+ cfg: { issuer: string; clientId: string },
454
+ req: Request,
455
+ ): Promise<OpMember | null> {
456
+ const m = /^Bearer\s+(.+)$/i.exec((req.headers.get("Authorization") ?? "").trim());
457
+ const token = m?.[1]?.trim();
458
+ if (!token || token.split(".").length === 3) return null;
459
+ const ids = deviceClientIds(env);
460
+ if (!ids.length || !cfg.issuer || !cfg.clientId) return null;
461
+ const key = `opint:${await sha256Hex(token)}`;
462
+ let answer: any = null;
463
+ try {
464
+ answer = await env.CACHE.get(key, "json");
465
+ } catch {
466
+ /* a KV hiccup costs one extra introspection */
467
+ }
468
+ if (answer === undefined || answer === null) {
469
+ try {
470
+ const res = await fetch(`${cfg.issuer}/op/introspect`, {
471
+ method: "POST",
472
+ headers: { "content-type": "application/x-www-form-urlencoded" },
473
+ body: new URLSearchParams({ token, client_id: cfg.clientId }),
474
+ });
475
+ answer = res.ok ? await res.json() : { active: false };
476
+ } catch {
477
+ return null;
478
+ }
479
+ try {
480
+ await env.CACHE.put(key, JSON.stringify(answer ?? { active: false }), { expirationTtl: 45 });
481
+ } catch {
482
+ /* cacheless = introspect per ask */
483
+ }
484
+ }
485
+ return opMemberFromIntrospection(answer, ids);
486
+ }