@konneal/engine 0.2.21 → 0.2.23

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,33 +1,3 @@
1
- var __create = Object.create;
2
- var __defProp = Object.defineProperty;
3
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __getProtoOf = Object.getPrototypeOf;
6
- var __hasOwnProp = Object.prototype.hasOwnProperty;
7
- var __commonJS = (cb, mod) => function __require() {
8
- try {
9
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
10
- } catch (e) {
11
- throw mod = 0, e;
12
- }
13
- };
14
- var __copyProps = (to, from, except, desc) => {
15
- if (from && typeof from === "object" || typeof from === "function") {
16
- for (let key of __getOwnPropNames(from))
17
- if (!__hasOwnProp.call(to, key) && key !== except)
18
- __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
19
- }
20
- return to;
21
- };
22
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
23
- // If the importer is in node compatibility mode or this is not an ESM
24
- // file that has been converted to a CommonJS file using a Babel-
25
- // compatible transform (i.e. "__esModule" has not been set), then set
26
- // "default" to the CommonJS "module.exports" for node compatibility.
27
- isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
28
- mod
29
- ));
30
-
31
1
  // workers/worker_public/src/profile.gen.ts
32
2
  var PROFILE = {
33
3
  "publisher": {
@@ -176,8 +146,6 @@ function P() {
176
146
  }
177
147
 
178
148
  export {
179
- __commonJS,
180
- __toESM,
181
149
  setProfile,
182
150
  P
183
151
  };
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  DATASETS,
3
3
  datasetAllowed
4
- } from "./chunk-RESCBSX6.js";
4
+ } from "./chunk-434NRPSS.js";
5
5
  import {
6
6
  P
7
- } from "./chunk-YTWPZE5O.js";
7
+ } from "./chunk-HYI32HMI.js";
8
8
 
9
9
  // workers/worker_public/src/requestScope.ts
10
10
  function licenseDeclared() {
@@ -7,24 +7,24 @@ import {
7
7
  sessionFrom,
8
8
  telemetry,
9
9
  understandQuery
10
- } from "./chunk-DGFJUA2C.js";
10
+ } from "./chunk-HXWDVAVN.js";
11
11
  import {
12
12
  corsHeaders,
13
13
  err,
14
14
  json,
15
15
  readJson,
16
16
  validateQuery
17
- } from "./chunk-BV7IKH3N.js";
17
+ } from "./chunk-FYGJDJPN.js";
18
18
  import {
19
19
  entitlementScope,
20
20
  standardKeysFrom
21
- } from "./chunk-COKARI7O.js";
21
+ } from "./chunk-IOZAZA7Y.js";
22
22
  import {
23
23
  LIMITS,
24
24
  MODELS,
25
25
  num,
26
26
  sha256Hex
27
- } from "./chunk-RESCBSX6.js";
27
+ } from "./chunk-434NRPSS.js";
28
28
 
29
29
  // workers/worker_public/src/search.ts
30
30
  async function handleSearch(env, ctx, req, tier, key) {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  P
3
- } from "./chunk-YTWPZE5O.js";
3
+ } from "./chunk-HYI32HMI.js";
4
4
 
5
5
  // workers/worker_public/src/refusal.ts
6
6
  function refusalAnswer() {
@@ -12,10 +12,9 @@ export interface LiveDataConfig {
12
12
  clientSecret?: string;
13
13
  }
14
14
  export declare function liveDataConfig(env: any): LiveDataConfig | null;
15
- /** Retain the sign-in's OP access token for the session's exchange
16
- * window (the OP token's own TTL, minus a small margin). Called ONCE
17
- * per sign-in from the auth callback; the token never persists past
18
- * the window. */
15
+ export declare function retainRefreshToken(env: any, sessionRaw: string, refreshToken: string): Promise<void>;
16
+ export declare function readRefreshToken(env: any, sessionRaw: string): Promise<string | null>;
17
+ export declare function dropRefreshToken(env: any, sessionRaw: string): Promise<void>;
19
18
  export declare function retainOpAccessToken(env: any, sessionRaw: string, opAccessToken: string, expiresInSec: number): Promise<void>;
20
19
  /** Drop the retained subject + the cached exchanged token (the sign-out
21
20
  * closes the window deliberately, never by expiry alone). */
@@ -0,0 +1,19 @@
1
+ import type { SessionClaims } from "./session";
2
+ export interface RenewedSession {
3
+ claims: Omit<SessionClaims, "iat" | "exp">;
4
+ refreshToken: string;
5
+ }
6
+ export type RenewOutcome = {
7
+ kind: "ok";
8
+ renewed: RenewedSession;
9
+ } | {
10
+ kind: "revoked";
11
+ } | {
12
+ kind: "unavailable";
13
+ };
14
+ export declare function renewSessionClaims(env: any, cfg: {
15
+ issuer: string;
16
+ clientId: string;
17
+ clientSecret?: string;
18
+ sessionSecret: string;
19
+ }, session: SessionClaims, sessionRaw: string): Promise<RenewOutcome>;
package/dist/oidc.d.ts CHANGED
@@ -37,6 +37,7 @@ export declare function buildAuthorizationUrl(metadata: OidcMetadata, params: {
37
37
  export interface OidcTokenResponse {
38
38
  id_token: string;
39
39
  access_token?: string;
40
+ refresh_token?: string;
40
41
  token_type?: string;
41
42
  expires_in?: number;
42
43
  }
@@ -0,0 +1,28 @@
1
+ import type { OidcIdTokenClaims, OidcTokenResponse } from "./oidc.ts";
2
+ import type { SessionClaims } from "./session";
3
+ export interface OidcMetadata {
4
+ token_endpoint: string;
5
+ jwks_uri: string;
6
+ }
7
+ export type RefreshResult = {
8
+ kind: "ok";
9
+ token: OidcTokenResponse;
10
+ } | {
11
+ kind: "refused";
12
+ } | {
13
+ kind: "unavailable";
14
+ };
15
+ /** The refresh grant's backchannel: the same shape as the code
16
+ * exchange's POST (the OP's WAF wants the issuer's own origin), with
17
+ * the three outcomes the renewal policy needs — a 400 is the OP
18
+ * REFUSING (revocation, expiry: dead), a network throw is
19
+ * unavailability (keep the session, retry next load). */
20
+ export declare function refreshGrant(meta: OidcMetadata, cfg: {
21
+ clientId: string;
22
+ clientSecret?: string;
23
+ }, refreshToken: string): Promise<RefreshResult>;
24
+ /** The fresh session claims from the refresh grant's ID token. The sub
25
+ * MUST match (a different account is a provisioning error, never an
26
+ * update); every profile field falls back to the standing session's so
27
+ * a claim the OP omits never erases what the user already had. */
28
+ export declare function renewedSessionClaims(session: SessionClaims, claims: OidcIdTokenClaims): Omit<SessionClaims, "iat" | "exp"> | null;
@@ -0,0 +1,12 @@
1
+ import {
2
+ handleSearch
3
+ } from "./chunk-PZLYPUYJ.js";
4
+ import "./chunk-HXWDVAVN.js";
5
+ import "./chunk-FYGJDJPN.js";
6
+ import "./chunk-RZ3UZVRR.js";
7
+ import "./chunk-IOZAZA7Y.js";
8
+ import "./chunk-434NRPSS.js";
9
+ import "./chunk-HYI32HMI.js";
10
+ export {
11
+ handleSearch
12
+ };
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  authenticate
3
- } from "../../chunk-BV7IKH3N.js";
4
- import "../../chunk-RESCBSX6.js";
3
+ } from "../../chunk-FYGJDJPN.js";
4
+ import "../../chunk-434NRPSS.js";
5
5
  import {
6
6
  P,
7
7
  setProfile
8
- } from "../../chunk-YTWPZE5O.js";
8
+ } from "../../chunk-HYI32HMI.js";
9
9
 
10
10
  // workers/worker_mcp/src/index.ts
11
11
  var PROTOCOL_VERSION = "2025-06-18";
@@ -16,8 +16,8 @@ import {
16
16
  roleModel,
17
17
  sha256Hex,
18
18
  today
19
- } from "../../chunk-RESCBSX6.js";
20
- import "../../chunk-YTWPZE5O.js";
19
+ } from "../../chunk-434NRPSS.js";
20
+ import "../../chunk-HYI32HMI.js";
21
21
  export {
22
22
  DATASETS,
23
23
  LIMITS,
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  handleSearch
3
- } from "../../chunk-SLZ4TZNO.js";
3
+ } from "../../chunk-PZLYPUYJ.js";
4
4
  import {
5
5
  checkQuoteAnchors,
6
6
  handleAsk,
7
7
  handleMemories,
8
8
  scoreJudge,
9
9
  standardForDocNumber
10
- } from "../../chunk-EE6V6X6D.js";
10
+ } from "../../chunk-GZZWHWGM.js";
11
11
  import {
12
12
  buildMessages,
13
13
  citations,
@@ -29,7 +29,7 @@ import {
29
29
  sessionFrom,
30
30
  telemetry,
31
31
  understandQuery
32
- } from "../../chunk-DGFJUA2C.js";
32
+ } from "../../chunk-HXWDVAVN.js";
33
33
  import {
34
34
  authenticate,
35
35
  corsHeaders,
@@ -38,14 +38,14 @@ import {
38
38
  readJson,
39
39
  validateQuery,
40
40
  withCors
41
- } from "../../chunk-BV7IKH3N.js";
41
+ } from "../../chunk-FYGJDJPN.js";
42
42
  import {
43
43
  canonicalRefusal
44
- } from "../../chunk-FNJ457SE.js";
44
+ } from "../../chunk-RZ3UZVRR.js";
45
45
  import {
46
46
  entitlementScope,
47
47
  standardKeysFrom
48
- } from "../../chunk-COKARI7O.js";
48
+ } from "../../chunk-IOZAZA7Y.js";
49
49
  import {
50
50
  LIMITS,
51
51
  MODELS,
@@ -56,11 +56,11 @@ import {
56
56
  roleModel,
57
57
  sha256Hex,
58
58
  today
59
- } from "../../chunk-RESCBSX6.js";
59
+ } from "../../chunk-434NRPSS.js";
60
60
  import {
61
61
  P,
62
62
  setProfile
63
- } from "../../chunk-YTWPZE5O.js";
63
+ } from "../../chunk-HYI32HMI.js";
64
64
 
65
65
  // workers/worker_public/src/conversations.ts
66
66
  var ID_RE = /^[a-zA-Z0-9_-]{8,64}$/;
@@ -979,7 +979,7 @@ async function handleMcp(env, ctx, req, tier, key) {
979
979
  // stream:false forces the JSON lane (anon defaults to SSE)
980
980
  body: JSON.stringify({ ...args, stream: false })
981
981
  });
982
- const res = name === "ask" ? await (await import("../../ask-36JS3555.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-YA6TD6QJ.js")).handleSearch(env, ctx, inner, tier, key);
982
+ const res = name === "ask" ? await (await import("../../ask-U3DLB37U.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-OJ776DMH.js")).handleSearch(env, ctx, inner, tier, key);
983
983
  return res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
984
984
  });
985
985
  if (out.ok && "accepted" in out) return new Response(null, { status: 202 });
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  P,
3
3
  setProfile
4
- } from "../../chunk-YTWPZE5O.js";
4
+ } from "../../chunk-HYI32HMI.js";
5
5
  export {
6
6
  P,
7
7
  setProfile
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  canonicalRefusal,
3
3
  refusalAnswer
4
- } from "../../chunk-FNJ457SE.js";
5
- import "../../chunk-YTWPZE5O.js";
4
+ } from "../../chunk-RZ3UZVRR.js";
5
+ import "../../chunk-HYI32HMI.js";
6
6
  export {
7
7
  canonicalRefusal,
8
8
  refusalAnswer
@@ -4,9 +4,9 @@ import {
4
4
  requestSalt,
5
5
  resolveRequestScope,
6
6
  standardKeysFrom
7
- } from "../../chunk-COKARI7O.js";
8
- import "../../chunk-RESCBSX6.js";
9
- import "../../chunk-YTWPZE5O.js";
7
+ } from "../../chunk-IOZAZA7Y.js";
8
+ import "../../chunk-434NRPSS.js";
9
+ import "../../chunk-HYI32HMI.js";
10
10
  export {
11
11
  entitlementScope,
12
12
  licenseDeclared,
package/package.json CHANGED
@@ -16,19 +16,18 @@
16
16
  "gen:profile": "node scripts/gen_profile.mjs"
17
17
  },
18
18
  "devDependencies": {
19
+ "@astrojs/markdown-satteri": "^0.4.1",
20
+ "@astrojs/mdx": "^4.3.14",
19
21
  "@cloudflare/workers-types": "^5.20260911.1",
22
+ "@konneal/client": "^0.2.1",
23
+ "@pubid/pubid": "^0.1.5",
20
24
  "esbuild": "^0.28.2",
21
25
  "jsdom": "^29.1.1",
22
26
  "openapi-typescript": "^7.13.0",
23
27
  "playwright": "^1.62.1",
24
- "yaml": "^2.9.1",
25
- "@astrojs/markdown-satteri": "^0.4.1",
26
- "@astrojs/mdx": "^4.3.14",
27
- "@konneal/client": "^0.2.1",
28
- "@oimlsmart/oiml-pubid": "^1.2.1"
28
+ "yaml": "^2.9.1"
29
29
  },
30
- "dependencies": {},
31
- "version": "0.2.21",
30
+ "version": "0.2.23",
32
31
  "description": "The Konneal engine: the publisher-agnostic build pipeline and API plane for standards intelligence (retrieval, answer contract, verdicts, evaluation).",
33
32
  "license": "BSD-3-Clause",
34
33
  "type": "module",
@@ -14,6 +14,8 @@ import {
14
14
  OidcError,
15
15
  } from "./oidc";
16
16
  import { clearSessionCookie, mintSessionCookie, mintSessionToken, rawSessionToken, readSession, sessionCookieFromToken, SessionClaims } from "./session";
17
+ import { renewSessionClaims } from "./oidc-refresh";
18
+ import { retainRefreshToken } from "./livedata";
17
19
  import { bubbleConfirmPage, isAllowedBubbleOrigin } from "./bubble";
18
20
  import { dropOpAccessToken, retainOpAccessToken } from "./livedata";
19
21
 
@@ -47,6 +49,9 @@ export interface AuthConfig {
47
49
  sessionSecret: string;
48
50
  }
49
51
 
52
+ // how long a session serves before renewal re-judges it against the OP
53
+ const RENEW_AFTER_MS = 60 * 60 * 1000;
54
+
50
55
  function authConfig(env: any): AuthConfig | null {
51
56
  const issuer = (env.OIDC_ISSUER ?? "").trim().replace(/\/$/, "");
52
57
  const clientId = env.OIDC_CLIENT_ID;
@@ -89,7 +94,7 @@ export async function handleLogin(env: any, req: Request): Promise<Response> {
89
94
  const url = buildAuthorizationUrl(meta, {
90
95
  clientId: cfg.clientId,
91
96
  redirectUri: cfg.redirectUri,
92
- scopes: "openid profile email roles",
97
+ scopes: "openid profile email roles offline_access",
93
98
  state,
94
99
  nonce,
95
100
  codeChallenge: pkce.challenge,
@@ -181,6 +186,11 @@ export async function handleCallback(env: any, req: Request): Promise<Response>
181
186
  if (typeof token.access_token === "string" && typeof token.expires_in === "number") {
182
187
  await retainOpAccessToken(env, session.token, token.access_token, token.expires_in);
183
188
  }
189
+ // the session's lifeline (oidc-refresh.ts): the OP's refresh token
190
+ // is what makes renewal a RE-JUDGMENT instead of a re-stamp
191
+ if (typeof token.refresh_token === "string" && token.refresh_token) {
192
+ await retainRefreshToken(env, session.token, token.refresh_token);
193
+ }
184
194
  // Bubble bridge: hand the session to the embedded panel as a Bearer
185
195
  // token via the confirm page — postMessage to the validated origin
186
196
  // ONLY, and only on the user's explicit click (bubble.ts). The
@@ -211,9 +221,34 @@ export async function handleMe(env: any, req: Request): Promise<Response> {
211
221
  const cfg = authConfig(env);
212
222
  const session = cfg ? await readSession(req, cfg.sessionSecret) : null;
213
223
  const headers: Record<string, string> = { "content-type": "application/json" };
214
- // sliding renewal: a session older than a day re-mints on sight, so
215
- // active users never hit the 7-day wall mid-conversation
216
- if (session && cfg && Date.now() - session.iat > 24 * 3600 * 1000) {
224
+ // Renewal is a RE-JUDGMENT, not a re-stamp (oidc-refresh.ts): a
225
+ // session older than the threshold presents the OP's refresh token,
226
+ // whose grant re-judges the live standing — a deactivated account's
227
+ // grant family is dead, the OP refuses, and the session ENDS here
228
+ // instead of silently serving stale claims for a week. A session with
229
+ // no stored refresh token (pre-offline_access logins) falls back to
230
+ // the legacy re-stamp; a network refusal keeps the session and
231
+ // retries on the next load.
232
+ let effective: SessionClaims | null = session;
233
+ if (session && cfg && Date.now() - session.iat > RENEW_AFTER_MS) {
234
+ const raw = rawSessionToken(req);
235
+ if (raw) {
236
+ const renewed = await renewSessionClaims(env, { issuer: cfg.issuer, clientId: cfg.clientId, clientSecret: env.OIDC_CLIENT_SECRET, sessionSecret: cfg.sessionSecret }, session, raw);
237
+ if (renewed.kind === "ok") {
238
+ const freshClaims = renewed.renewed.claims;
239
+ const minted = await mintSessionToken(cfg.sessionSecret, freshClaims);
240
+ effective = { ...freshClaims, iat: Date.now(), exp: minted.expiresAt };
241
+ headers["set-cookie"] = sessionCookieFromToken(minted.token);
242
+ } else if (renewed.kind === "revoked") {
243
+ effective = null;
244
+ headers["set-cookie"] = clearSessionCookie();
245
+ }
246
+ }
247
+ }
248
+ if (effective && cfg && session && effective === session && Date.now() - session.iat > 24 * 3600 * 1000) {
249
+ // the legacy re-stamp: no refresh token available (pre-offline_access
250
+ // sessions) — the claims are what they were, but the 7-day wall still
251
+ // slides for active users
217
252
  headers["set-cookie"] = await mintSessionCookie(cfg.sessionSecret, {
218
253
  sub: session.sub,
219
254
  name: session.name,
@@ -224,12 +259,12 @@ export async function handleMe(env: any, req: Request): Promise<Response> {
224
259
  }
225
260
  return new Response(
226
261
  JSON.stringify({
227
- authenticated: !!session,
228
- name: session?.name ?? null,
229
- email: session?.email ?? null,
230
- picture: session?.picture ?? null,
231
- roles: session?.roles ?? [],
232
- tier: session ? "member" : "anon",
262
+ authenticated: !!effective,
263
+ name: effective?.name ?? null,
264
+ email: effective?.email ?? null,
265
+ picture: effective?.picture ?? null,
266
+ roles: effective?.roles ?? [],
267
+ tier: effective ? "member" : "anon",
233
268
  sign_in_available: !!cfg,
234
269
  }),
235
270
  { headers },
@@ -24,14 +24,57 @@ export interface RefCodec {
24
24
  familyOf(docidentifier: string): string | null;
25
25
  }
26
26
 
27
- // The OIML grammar is @oimlsmart/oiml-pubid — the estate's single
28
- // source of truth (real tokenizer: editions, amendments, languages,
29
- // the CS family — beyond what any regex here carried). This codec's
30
- // job is the ADAPTATION: the parser takes the prefixed form and
31
- // returns null for everything else; our callers also send bare forms
32
- // ("R 60-1:2021") and the URN provenance shape. Corpi: the package
33
- // ships the shared conformance corpus; our tests run against it.
34
- import { parseOimlPubid } from "@oimlsmart/oiml-pubid";
27
+ // The OIML grammar is the unified pubid grammar (@pubid/pubid's oiml
28
+ // flavor — single-flavor import so the worker bundle carries the OIML
29
+ // grammar and nothing else). This codec's job is the ADAPTATION: the
30
+ // grammar takes the prefixed form and returns null for everything
31
+ // else; our callers also send bare forms ("R 60-1:2021"), the URN
32
+ // provenance shape, and the dual-published print ("ISO
33
+ // 4064-1:2024|OIML R 49-1:2024") whose retrieval spine is its OIML
34
+ // side, whichever side prints first. The estate's former grammar
35
+ // (@oimlsmart/oiml-pubid) retired into this one (pubid/pubid-ts#63).
36
+ import { oimlGrammarImplementation } from "@pubid/pubid/dist/flavors/oiml/implementation.js";
37
+
38
+ const oimlParser = oimlGrammarImplementation();
39
+
40
+ const TYPE_LETTER: Record<string, string> = {
41
+ recommendation: "R", document: "D", basic_publication: "B",
42
+ "basic-publication": "B", guide: "G", expert_report: "E",
43
+ "expert-report": "E", vocabulary: "V", seminar_report: "S",
44
+ "seminar-report": "S",
45
+ };
46
+
47
+ interface PubidHash {
48
+ _type?: string;
49
+ number?: string | number;
50
+ part?: string;
51
+ year?: string;
52
+ edition?: string;
53
+ }
54
+
55
+ /** The spine of a printed identifier through the unified grammar: the
56
+ * type letter, the normalized number, the part and the edition year.
57
+ * A dual-published print resolves to its OIML member (the caller picks
58
+ * the side); unknown type kinds refuse — never a fabricated letter. */
59
+ function parseOimlSpine(display: string): { doc_number: string; edition?: string; label: string } | null {
60
+ try {
61
+ const h = oimlParser.parse(display.trim()).toHash() as PubidHash;
62
+ if (h.number === undefined) return null;
63
+ const kind = String(h._type ?? "").split(":").pop() ?? "";
64
+ const letter = TYPE_LETTER[kind] ?? "";
65
+ if (!letter) return null;
66
+ const num = String(Number(h.number));
67
+ const part = h.part !== undefined ? String(h.part) : undefined;
68
+ const ed = h.year !== undefined ? String(h.year) : h.edition !== undefined ? String(h.edition) : undefined;
69
+ return {
70
+ doc_number: num,
71
+ ...(ed ? { edition: ed } : {}),
72
+ label: `OIML ${letter} ${num}${part ? `-${part}` : ""}${ed ? `:${ed}` : ""}`,
73
+ };
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
35
78
 
36
79
  /** urn:oiml:pub:r:60-1:2021 (pub) / urn:oiml:pub:cs:pd-06 (CS) → the
37
80
  * prefixed display form the parser takes. */
@@ -44,8 +87,15 @@ const urnToDisplay = (u: string) => {
44
87
  };
45
88
 
46
89
  const parsePubid = (doc: string) => {
90
+ // a dual-published print: the OIML side is the spine, whichever side
91
+ // prints first — pick it before the bare-form normalization, which
92
+ // would mis-prefix the co-publisher's side
93
+ if (!/^urn:/i.test(doc) && doc.includes("|")) {
94
+ const side = doc.split("|").map((s) => s.trim()).find((s) => /^(?:OIML|oiml)\b/i.test(s));
95
+ return side ? parseOimlSpine(side) : null;
96
+ }
47
97
  const src = /^urn:/i.test(doc) ? urnToDisplay(doc) : /^(?:OIML|oiml)\b/i.test(doc) ? doc : `OIML ${doc}`;
48
- return src ? parseOimlPubid(src) : null;
98
+ return src ? parseOimlSpine(src) : null;
49
99
  };
50
100
 
51
101
  /** OIML's grammar (delegated): type letter + 1–3 digits, optional part,
@@ -54,11 +104,12 @@ const parsePubid = (doc: string) => {
54
104
  export const oimlPubid: RefCodec = {
55
105
  parse(doc, edition) {
56
106
  const p = parsePubid(doc);
57
- if (!p || p.series !== "pub") return null;
58
- const type = p.family.toUpperCase();
59
- const num = String(Number(p.number)); // R 060 → R 60 (display and steering agree)
60
- const ed = edition ?? p.year ?? undefined;
61
- return { doc_number: num, ...(ed ? { edition: ed } : {}), label: `OIML ${type} ${num}${p.part ? `-${p.part}` : ""}${ed ? `:${ed}` : ""}` };
107
+ if (!p) return null;
108
+ // the caller's edition is bibdata's truth — it wins over the print's
109
+ if (edition && p.edition !== edition) {
110
+ return { ...p, edition, label: p.label.split(":")[0] + `:${edition}` };
111
+ }
112
+ return p;
62
113
  },
63
114
  scanQuestion(query) {
64
115
  const re = /\b(OIML\s+)?([RDBGE])(\s*)0*(\d{1,3})(?:\s*[-–]\s*\d+)?(?:\s*:\s*(\d{4}))?/gi;
@@ -78,7 +129,10 @@ export const oimlPubid: RefCodec = {
78
129
  },
79
130
  familyOf(di) {
80
131
  const p = parsePubid(di);
81
- if (p && p.series === "pub") return `${p.family.toUpperCase()}-${String(Number(p.number))}`;
132
+ if (p) {
133
+ const m = /^OIML ([A-Z]+) (\d{1,3})/.exec(p.label);
134
+ return m ? `${m[1]}-${m[2]}` : null;
135
+ }
82
136
  const m = /^(?:OIML\s+)?([A-Z])\s?(\d{1,3})(?:[-–]([0-9A-Za-z]+))?/.exec(di);
83
137
  return m ? `${m[1]}-${m[2]}` : null;
84
138
  },
@@ -65,6 +65,41 @@ const EXCHANGED_KEY = (sessionHash: string) => `ossx:${sessionHash}`;
65
65
  * window (the OP token's own TTL, minus a small margin). Called ONCE
66
66
  * per sign-in from the auth callback; the token never persists past
67
67
  * the window. */
68
+ // The session's refresh-token store (oidc-refresh.ts): the OP's refresh
69
+ // grant is what makes session renewal a RE-JUDGMENT of live standing
70
+ // instead of a re-stamp of frozen claims. Same KV discipline as the
71
+ // access token: keyed by the session token's hash, TTL = the grant's
72
+ // own life, never D1.
73
+ const REFRESH_KEY = (sessionHash: string) => `ort:${sessionHash}`;
74
+ const REFRESH_TTL_SEC = 30 * 24 * 60 * 60; // the OP's grant TTL default
75
+
76
+ export async function retainRefreshToken(env: any, sessionRaw: string, refreshToken: string): Promise<void> {
77
+ try {
78
+ await env.CACHE.put(REFRESH_KEY(await sha256Hex(sessionRaw)), JSON.stringify({ token: refreshToken }), {
79
+ expirationTtl: REFRESH_TTL_SEC,
80
+ });
81
+ } catch {
82
+ // a KV hiccup degrades to the legacy staleness, never blocks sign-in
83
+ }
84
+ }
85
+
86
+ export async function readRefreshToken(env: any, sessionRaw: string): Promise<string | null> {
87
+ try {
88
+ const hit = await env.CACHE.get(REFRESH_KEY(await sha256Hex(sessionRaw)), "json");
89
+ return typeof hit?.token === "string" ? hit.token : null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ export async function dropRefreshToken(env: any, sessionRaw: string): Promise<void> {
96
+ try {
97
+ await env.CACHE.delete(REFRESH_KEY(await sha256Hex(sessionRaw)));
98
+ } catch {
99
+ // best effort
100
+ }
101
+ }
102
+
68
103
  export async function retainOpAccessToken(
69
104
  env: any,
70
105
  sessionRaw: string,