@tokenoftrust/storefront-runner 4.11.15 → 4.11.16

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.
@@ -204,6 +204,18 @@ export async function updateSession(
204
204
  return next;
205
205
  }
206
206
 
207
+ /** Persist an authenticated record under a fresh id without extending its lifetime. */
208
+ export async function rotateSession(
209
+ store: SessionStore,
210
+ record: SessionRecord,
211
+ nowSeconds: number,
212
+ ): Promise<{ id: string; record: SessionRecord } | null> {
213
+ if (record.exp <= nowSeconds) return null;
214
+ const id = newSessionId();
215
+ await store.put(id, JSON.stringify(record), record.exp - nowSeconds);
216
+ return { id, record };
217
+ }
218
+
207
219
  /** Revoke a session (logout / admin kill). Idempotent. */
208
220
  export async function revokeSession(
209
221
  store: SessionStore,
@@ -10,15 +10,15 @@
10
10
  * minted from the VERIFIED session email (no plaintext email on the wire);
11
11
  * 2. tot20 re-checks staff scope, scopes the capability, and writes the AUDIT row —
12
12
  * it is the authority, and nothing is admitted without its ok;
13
- * 3. stamp the short-lived {@link StaffSelection} on the session (never a `tenants[]`
14
- * entry), so re-entering after it lapses re-audits;
13
+ * 3. persist the short-lived {@link StaffSelection} under a fresh session id (never a
14
+ * `tenants[]` entry), preserving the original expiry; re-entry re-audits;
15
15
  * 4. hand every exit's outcome to the caller's `emit`, which records it as
16
16
  * `dashboard.vendor_entered` against its opaque admin actor id (the email is
17
17
  * never stored).
18
18
  */
19
19
  import type { SessionRecord, SessionStore, StaffSelection } from "@/lib/access/auth/session";
20
- import { updateSession } from "@/lib/access/auth/session";
21
- import { isStaffSession, buildStaffSelection } from "./staffAdmission";
20
+ import { readSession, rotateSession } from "@/lib/access/auth/session";
21
+ import { isStaffSession, buildStaffSelection, STAFF_SELECTION_TTL_SECONDS } from "./staffAdmission";
22
22
  import {
23
23
  createStaffVendorAccessClient,
24
24
  staffAccessFailureMessage,
@@ -41,18 +41,19 @@ export interface StaffStoreEntryInput {
41
41
  /** Records each entry outcome; the route binds it to its opaque admin actor. */
42
42
  emit: (event: StaffEntryEvent) => void;
43
43
  /** The persisted session to stamp the selection on; null when there is no store. */
44
- sessionHandle: () => Promise<{ store: SessionStore; id: string } | null>;
44
+ /** A null id is a freshly verified, not-yet-persisted sign-in record. */
45
+ sessionHandle: () => Promise<{ store: SessionStore; id: string | null } | null>;
45
46
  }
46
47
 
47
48
  export type StaffStoreEntryResult =
48
- | { ok: true; selection: StaffSelection; capability: string }
49
+ | { ok: true; selection: StaffSelection; capability: string; sessionId: string; expiresIn: number }
49
50
  | {
50
51
  ok: false;
51
52
  status: number;
52
53
  body: { error: string; reason?: string; code?: string };
53
54
  };
54
55
 
55
- /** Enter `vendorAppDomain` as ToT staff: audit with tot20, then persist the selection. */
56
+ /** Audit with tot20, then persist the selection under a fresh, expiry-preserving id. */
56
57
  export async function enterStoreAsStaff(input: StaffStoreEntryInput): Promise<StaffStoreEntryResult> {
57
58
  const { session, emit } = input;
58
59
  const vendorAppDomain = input.vendorAppDomain.trim();
@@ -99,6 +100,7 @@ export async function enterStoreAsStaff(input: StaffStoreEntryInput): Promise<St
99
100
  resource: vendorAppDomain,
100
101
  capability: result.capability,
101
102
  nowSeconds,
103
+ ttlSeconds: Math.max(1, Math.min(STAFF_SELECTION_TTL_SECONDS, session.exp - nowSeconds)),
102
104
  });
103
105
  const handle = await input.sessionHandle();
104
106
  if (!handle) {
@@ -111,12 +113,25 @@ export async function enterStoreAsStaff(input: StaffStoreEntryInput): Promise<St
111
113
  });
112
114
  return { ok: false, status: 503, body: { error: "session store unavailable; cannot persist admission" } };
113
115
  }
114
- const updated = await updateSession(
115
- handle.store,
116
- handle.id,
117
- (rec) => ({ ...rec, staffSelection: selection }),
118
- nowSeconds,
119
- );
116
+ let updated;
117
+ try {
118
+ const current = handle.id ? await readSession(handle.store, handle.id, nowSeconds) : session;
119
+ updated = current && current.email === session.email && current.subject === session.subject && isStaffSession(current) && current.exp > nowSeconds
120
+ ? await rotateSession(handle.store, {
121
+ ...current,
122
+ createdAt: session.createdAt,
123
+ exp: Math.min(current.exp, session.exp),
124
+ staffSelection: { ...selection, exp: Math.min(selection.exp, current.exp, session.exp) },
125
+ }, nowSeconds)
126
+ : null;
127
+ } catch {
128
+ emit({
129
+ outcome: { status: "failed", errorClass: "session_store_unavailable" },
130
+ scope: { tenantId: vendorAppDomain },
131
+ payload: { args: { resource: vendorAppDomain, capability: result.capability } },
132
+ });
133
+ return { ok: false, status: 503, body: { error: "session store unavailable; cannot persist admission" } };
134
+ }
120
135
  if (!updated) {
121
136
  emit({
122
137
  outcome: { status: "failed", errorClass: "session_expired" },
@@ -131,5 +146,5 @@ export async function enterStoreAsStaff(input: StaffStoreEntryInput): Promise<St
131
146
  scope: { tenantId: vendorAppDomain },
132
147
  payload: { args: { resource: vendorAppDomain, capability: result.capability } },
133
148
  });
134
- return { ok: true, selection, capability: result.capability };
149
+ return { ok: true, selection, capability: result.capability, sessionId: updated.id, expiresIn: updated.record.exp - nowSeconds };
135
150
  }
@@ -49,6 +49,7 @@ import type {
49
49
  } from "@tot/public-runtime";
50
50
  import type { ActionKey } from "@tot/public-runtime";
51
51
  import { readViewerSession } from "@/lib/access/auth/route";
52
+ import type { SessionRecord } from "@/lib/access/auth/session";
52
53
  import { sessionHostCapability } from "@/lib/access/auth/loginGate";
53
54
  import { isStaffSession } from "@/lib/access/dashboard/staffAdmission";
54
55
  import { readEnv } from "@/lib/publication/shared/env";
@@ -85,6 +86,8 @@ export function classifyActorKind(
85
86
  }
86
87
 
87
88
  export interface ResolveUiActorInput {
89
+ /** An already verified record for sign-in paths that do not yet have a cookie. */
90
+ session?: SessionRecord;
88
91
  /**
89
92
  * Fallback acting identity for the HEADLESS path (no signed-in viewer) — typically
90
93
  * the resolved tenant owner (`auth.owner`). Only used to seed the opaque id when
@@ -107,12 +110,12 @@ export async function resolveUiActor(
107
110
  ): Promise<ActivityActor> {
108
111
  // The host-bound `locals.viewer` chrome omits `staff[]`, so read the full session
109
112
  // to tell a ToT-staff (admin) actor from a store owner (merchant). Best-effort.
110
- const session = await readViewerSession(context).catch(() => null);
113
+ const session = input.session ?? await readViewerSession(context).catch(() => null);
111
114
  // The signed-in identity is the host gate's `locals.viewer`, else the raw session (a
112
115
  // route that never went through that gate, e.g. the /dev cockpit's rendezvous
113
116
  // approval). Ownership is the session's standing on the ROUTED tenant only — a
114
117
  // session that owns another store is not this store's merchant.
115
- const email = context.locals?.viewer?.email ?? session?.email;
118
+ const email = input.session ? input.session.email : context.locals?.viewer?.email ?? session?.email;
116
119
  const viewer: ViewerLike | undefined = email
117
120
  ? { email, capability: sessionHostCapability(session, context.locals?.tenant?.appDomain ?? "") }
118
121
  : undefined;
@@ -52,6 +52,7 @@ export class FetchHttpTransport implements HttpTransport, HttpGetTransport {
52
52
  // body). A per-call or per-transport header of the same name still wins.
53
53
  headers: { "content-type": "application/json", ...this.defaultHeaders, ...headers },
54
54
  body: JSON.stringify(body),
55
+ redirect: "manual",
55
56
  signal: this.signal(),
56
57
  });
57
58
  return { status: res.status, json: () => res.json() };
@@ -60,6 +61,7 @@ export class FetchHttpTransport implements HttpTransport, HttpGetTransport {
60
61
  async get(url: string, headers: Record<string, string>): Promise<HttpResponseLike> {
61
62
  const res = await fetch(url, {
62
63
  method: "GET",
64
+ redirect: "manual",
63
65
  headers: { ...this.defaultHeaders, ...headers },
64
66
  signal: this.signal(),
65
67
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.11.15",
3
+ "version": "4.11.16",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",