@pramen/server 0.0.37 → 0.0.38

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/auth.js CHANGED
@@ -175,6 +175,10 @@ function toIdentity(claims) {
175
175
  ? [claims.role]
176
176
  : [];
177
177
  const identity = { roles, userId: (claims.sub ?? claims.userId) };
178
+ // Carry `exp` (a STANDARD claim, so the passthrough loop skips it) so a WebSocket can
179
+ // re-check expiry per message — its identity is fixed at upgrade and never re-verified.
180
+ if (typeof claims.exp === "number")
181
+ identity.exp = claims.exp;
178
182
  for (const [k, v] of Object.entries(claims)) {
179
183
  if (!STANDARD_CLAIMS.has(k))
180
184
  identity[k] = v; // carry custom claims (tier, …)
@@ -79,6 +79,13 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
79
79
  private get envBag();
80
80
  private filesFor;
81
81
  private identityOf;
82
+ /** Has this socket's token expired? `exp` (epoch seconds) rides the socket attachment
83
+ * from the upgrade-time identity. A synthetic / non-expiring identity has no `exp` and is
84
+ * never expired, so callPrivileged and admin sockets keep working. */
85
+ private isExpired;
86
+ /** Reject a message from an expired socket: send the protocol's auth-failure error frame,
87
+ * then close 4401 (the client should re-authenticate and reconnect). */
88
+ private rejectExpired;
82
89
  /** The durable per-socket auth/routing state (identity + tenant + partition), read
83
90
  * from the WS attachment. Survives hibernation; kept tiny to stay under workerd's cap. */
84
91
  private getAttachment;
@@ -24,13 +24,16 @@ import { Db } from "./runtime/db";
24
24
  import { digest } from "./runtime/digest";
25
25
  import { compileAcl } from "./runtime/acl";
26
26
  import { DoSqliteDriver } from "./runtime/driver";
27
- import { BadRequest, toResponse, toWsError } from "./runtime/errors";
27
+ import { BadRequest, Unauthorized, toResponse, toWsError } from "./runtime/errors";
28
28
  import { Kv } from "./runtime/kv";
29
29
  import { registryKey } from "./runtime/registry";
30
30
  import { DEFAULT_PARTITION } from "./sdk/schema";
31
31
  import { createFiles, R2Adapter } from "./runtime/storage";
32
32
  /** Per-socket subscription cap — bounds memory and per-mutation re-run cost. */
33
33
  const MAX_SUBSCRIPTIONS = 64;
34
+ /** WebSocket close code for an auth failure (RFC 6455 leaves 4000-4999 to the app;
35
+ * 4401 mirrors HTTP 401). Sent when a socket's token has expired since upgrade. */
36
+ const WS_CLOSE_UNAUTHORIZED = 4401;
34
37
  export class PramenDOBase extends DurableObject {
35
38
  app;
36
39
  acl;
@@ -276,9 +279,15 @@ export class PramenDOBase extends DurableObject {
276
279
  // socket's (tenant, partition) — fixed at connect time, survives via the attachment
277
280
  // — and ensure the schema is migrated before any handler/ctx.db work. Idempotent
278
281
  // (the `migrated` flag), so a no-op after the first call.
279
- const { tenant, partition } = this.getAttachment(ws);
280
- this.tenant = tenant;
281
- this.partition = partition;
282
+ const att = this.getAttachment(ws);
283
+ this.tenant = att.tenant;
284
+ this.partition = att.partition;
285
+ // The token was verified ONCE at upgrade; a live/hibernating socket can outlive its
286
+ // TTL. Re-check `exp` per message so an expired session can't keep calling/subscribing
287
+ // (role changes + the denylist only bite on reconnect, which this forces). Fail the
288
+ // frame and close 4401 so the client re-auths. Synthetic identities carry no exp.
289
+ if (this.isExpired(att.identity))
290
+ return this.rejectExpired(ws, msg.id);
282
291
  await this.ensureMigrated();
283
292
  switch (msg.type) {
284
293
  case "subscribe":
@@ -355,6 +364,19 @@ export class PramenDOBase extends DurableObject {
355
364
  for (const ws of this.ctx.getWebSockets()) {
356
365
  try {
357
366
  const att = this.getAttachment(ws);
367
+ // A live socket whose token expired while connected must not keep receiving pushes.
368
+ // Close it 4401 and drop its subscriptions instead of pushing (an expired hibernating
369
+ // socket already has no in-memory subs, so this only bites still-live ones).
370
+ if (this.isExpired(att.identity)) {
371
+ try {
372
+ ws.close(WS_CLOSE_UNAUTHORIZED, "session expired");
373
+ }
374
+ catch {
375
+ /* already closing */
376
+ }
377
+ this.subsBySocket.delete(ws);
378
+ continue;
379
+ }
358
380
  const subs = this.getSubs(ws);
359
381
  let dirty = false;
360
382
  for (const sub of subs) {
@@ -537,6 +559,24 @@ export class PramenDOBase extends DurableObject {
537
559
  return null;
538
560
  }
539
561
  }
562
+ /** Has this socket's token expired? `exp` (epoch seconds) rides the socket attachment
563
+ * from the upgrade-time identity. A synthetic / non-expiring identity has no `exp` and is
564
+ * never expired, so callPrivileged and admin sockets keep working. */
565
+ isExpired(identity) {
566
+ const exp = identity?.exp;
567
+ return typeof exp === "number" && Date.now() / 1000 >= exp;
568
+ }
569
+ /** Reject a message from an expired socket: send the protocol's auth-failure error frame,
570
+ * then close 4401 (the client should re-authenticate and reconnect). */
571
+ rejectExpired(ws, id) {
572
+ this.send(ws, toWsError(id, new Unauthorized("session expired")));
573
+ try {
574
+ ws.close(WS_CLOSE_UNAUTHORIZED, "session expired");
575
+ }
576
+ catch {
577
+ /* already closing */
578
+ }
579
+ }
540
580
  /** The durable per-socket auth/routing state (identity + tenant + partition), read
541
581
  * from the WS attachment. Survives hibernation; kept tiny to stay under workerd's cap. */
542
582
  getAttachment(ws) {
package/dist/index.d.ts CHANGED
@@ -8,6 +8,7 @@ export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, Han
8
8
  export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
9
9
  export type { Action, Identity, IdentityMarker, InputMarker, Policy, PolicyRule, PolicyRules, Role, Validator, WhereRule, ConditionalFields, FieldsFn, RelationAclRule, SetValue, ResolverFn, ResolverContext, ResolverDb, } from "./sdk/acl";
10
10
  export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, ProjectedRow, RelationsOf, RelationsResult, WhereClause, WhereInput, WhereOps, } from "./sdk/infer";
11
+ export { Kv, denySession, allowSession, isSessionDenied } from "./runtime/kv";
11
12
  export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } from "./sdk/files";
12
13
  export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
13
14
  export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
package/dist/index.js CHANGED
@@ -14,6 +14,8 @@ export { createApp } from "./sdk/app";
14
14
  export { query, mutation, authorizeHandler } from "./sdk/handlers";
15
15
  // --- ACL ---
16
16
  export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
17
+ // --- kv (ctx.kv) + session denylist (hard token revocation) ---
18
+ export { Kv, denySession, allowSession, isSessionDenied } from "./runtime/kv";
17
19
  export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
18
20
  // --- mail (ctx.mail) ---
19
21
  export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
@@ -21,3 +21,14 @@ export declare class Kv {
21
21
  cursor: string | null;
22
22
  }>;
23
23
  }
24
+ /** Revoke every outstanding token for `username` for up to `ttlSeconds` (the session
25
+ * TTL). Called when an account is deactivated / deleted. The entry self-expires, so the
26
+ * denylist never accumulates beyond the current revocation window. */
27
+ export declare function denySession(kv: Kv, username: string, ttlSeconds: number): Promise<void>;
28
+ /** Lift a prior `denySession` (e.g. on reactivation). The key is username-scoped, so
29
+ * without this a reactivated account would stay locked out — even for a fresh login —
30
+ * until the denylist entry expired on its own. */
31
+ export declare function allowSession(kv: Kv, username: string): Promise<void>;
32
+ /** Is there a live denylist entry for `username`? The Worker consults this per
33
+ * authenticated request (only when a `sub` is present) to fail a revoked token closed. */
34
+ export declare function isSessionDenied(kv: Kv, username: string): Promise<boolean>;
@@ -39,3 +39,40 @@ export class Kv {
39
39
  };
40
40
  }
41
41
  }
42
+ // --- session denylist (hard token revocation) -------------------------------
43
+ //
44
+ // The core is stateless verify-only: roles/active are baked into a token at login and
45
+ // there is no per-request DB lookup. To revoke a token BEFORE its `exp` (deactivate /
46
+ // delete / compromise), we keep a tiny KV denylist keyed by username (= the JWT `sub`).
47
+ // The Worker checks it right after resolving identity — a denied `sub` fails closed
48
+ // (401), never silently downgrades to anonymous.
49
+ //
50
+ // The entry carries an `expirationTtl` equal to the session TTL, so it self-expires
51
+ // exactly when the last token that could have been outstanding at revocation time does:
52
+ // the list can only ever hold recently-revoked users and never grows unbounded. The key
53
+ // is username-scoped (not per-token), so it blocks EVERY token for that user — including
54
+ // a fresh login — which is why reactivation must lift it (see `allowSession`).
55
+ /** App-relative key (the `Kv` facade adds its own `app:` prefix). Writer (auth) and
56
+ * reader (Worker) both route through these helpers so the namespacing always agrees. */
57
+ const denyKey = (username) => `authDenied:${username}`;
58
+ /** Cloudflare KV rejects an `expirationTtl` below 60s — clamp so a short session TTL
59
+ * still produces a valid (if slightly over-long) denylist entry. */
60
+ const MIN_KV_TTL_SECONDS = 60;
61
+ /** Revoke every outstanding token for `username` for up to `ttlSeconds` (the session
62
+ * TTL). Called when an account is deactivated / deleted. The entry self-expires, so the
63
+ * denylist never accumulates beyond the current revocation window. */
64
+ export async function denySession(kv, username, ttlSeconds) {
65
+ const ttl = Math.max(MIN_KV_TTL_SECONDS, Math.trunc(ttlSeconds) || MIN_KV_TTL_SECONDS);
66
+ await kv.put(denyKey(username), "1", { expirationTtl: ttl });
67
+ }
68
+ /** Lift a prior `denySession` (e.g. on reactivation). The key is username-scoped, so
69
+ * without this a reactivated account would stay locked out — even for a fresh login —
70
+ * until the denylist entry expired on its own. */
71
+ export async function allowSession(kv, username) {
72
+ await kv.delete(denyKey(username));
73
+ }
74
+ /** Is there a live denylist entry for `username`? The Worker consults this per
75
+ * authenticated request (only when a `sub` is present) to fail a revoked token closed. */
76
+ export async function isSessionDenied(kv, username) {
77
+ return (await kv.get(denyKey(username))) != null;
78
+ }
package/dist/sdk/acl.d.ts CHANGED
@@ -3,6 +3,11 @@ export type Action = "read" | "create" | "update" | "delete";
3
3
  export interface Identity {
4
4
  role?: string;
5
5
  roles?: string[];
6
+ /** The verified token's `exp` (epoch seconds), when present. Carried so a long-lived
7
+ * WebSocket — whose identity is fixed at upgrade and never re-verified — can enforce
8
+ * expiry per message (see durable-object.ts). Absent for non-expiring / synthetic
9
+ * (callPrivileged) identities, which are therefore never treated as expired. */
10
+ exp?: number;
6
11
  [key: string]: unknown;
7
12
  }
8
13
  declare const IDENTITY_MARKER: unique symbol;
package/dist/worker.js CHANGED
@@ -14,7 +14,7 @@ import { compileAcl } from "./runtime/acl";
14
14
  import { Db } from "./runtime/db";
15
15
  import { D1Driver } from "./runtime/driver";
16
16
  import { toResponse } from "./runtime/errors";
17
- import { Kv } from "./runtime/kv";
17
+ import { Kv, isSessionDenied } from "./runtime/kv";
18
18
  import { listDOs, partitionDoName } from "./runtime/registry";
19
19
  import { createFiles, handleFileRequest, handleMediaRequest, R2Adapter } from "./runtime/storage";
20
20
  import { DEFAULT_PARTITION, partitionsOf } from "./sdk/schema";
@@ -245,6 +245,17 @@ export function makeWorker(app) {
245
245
  req = new Request(request, { headers: h });
246
246
  }
247
247
  const identity = await resolveIdentity(req, strategyFor(env));
248
+ // Hard revocation (deactivate / delete / compromise), independent of token TTL: a
249
+ // revoked `sub` is on the KV denylist (written by @pramen/auth's setUserActive(false)
250
+ // / deleteUser, self-expiring at the session TTL). Check it HERE, at identity-resolution
251
+ // time — before the DO proxy AND the D1 store path, and before the admin routes — so it
252
+ // covers HTTP and the WebSocket upgrade alike. A denied token fails CLOSED (401); we do
253
+ // NOT silently downgrade to anonymous — a revoked user should see a clear auth failure.
254
+ // Synthetic identities (callPrivileged) never pass through here, so they're unaffected.
255
+ const sub = typeof identity?.userId === "string" ? identity.userId : undefined;
256
+ if (sub && (await isSessionDenied(new Kv(env.KV), sub))) {
257
+ return withCors(json({ ok: false, error: "session revoked", code: "unauthorized" }, 401), cors);
258
+ }
248
259
  // --- admin: list known (tenant, partition) DOs from the registry ---
249
260
  if (url.pathname === "/tenants") {
250
261
  if (!isAdmin(identity))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.37",
3
+ "version": "0.0.38",
4
4
  "description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/auth.ts CHANGED
@@ -226,6 +226,9 @@ function toIdentity(claims: Record<string, unknown>): Identity {
226
226
  ? [claims.role]
227
227
  : [];
228
228
  const identity: Identity = { roles, userId: (claims.sub ?? claims.userId) as string | undefined };
229
+ // Carry `exp` (a STANDARD claim, so the passthrough loop skips it) so a WebSocket can
230
+ // re-check expiry per message — its identity is fixed at upgrade and never re-verified.
231
+ if (typeof claims.exp === "number") identity.exp = claims.exp;
229
232
  for (const [k, v] of Object.entries(claims)) {
230
233
  if (!STANDARD_CLAIMS.has(k)) identity[k] = v; // carry custom claims (tier, …)
231
234
  }
@@ -25,7 +25,7 @@ import { Db } from "./runtime/db";
25
25
  import { digest } from "./runtime/digest";
26
26
  import { compileAcl, type AclContext, type CompiledAcl } from "./runtime/acl";
27
27
  import { DoSqliteDriver, type Driver } from "./runtime/driver";
28
- import { BadRequest, toResponse, toWsError } from "./runtime/errors";
28
+ import { BadRequest, Unauthorized, toResponse, toWsError } from "./runtime/errors";
29
29
  import { Kv } from "./runtime/kv";
30
30
  import { registryKey } from "./runtime/registry";
31
31
  import { DEFAULT_PARTITION } from "./sdk/schema";
@@ -65,6 +65,10 @@ export interface DoEnv {
65
65
  /** Per-socket subscription cap — bounds memory and per-mutation re-run cost. */
66
66
  const MAX_SUBSCRIPTIONS = 64;
67
67
 
68
+ /** WebSocket close code for an auth failure (RFC 6455 leaves 4000-4999 to the app;
69
+ * 4401 mirrors HTTP 401). Sent when a socket's token has expired since upgrade. */
70
+ const WS_CLOSE_UNAUTHORIZED = 4401;
71
+
68
72
  export class PramenDOBase extends DurableObject<DoEnv> {
69
73
  private readonly app: PramenApp;
70
74
  private readonly acl: CompiledAcl;
@@ -336,9 +340,16 @@ export class PramenDOBase extends DurableObject<DoEnv> {
336
340
  // socket's (tenant, partition) — fixed at connect time, survives via the attachment
337
341
  // — and ensure the schema is migrated before any handler/ctx.db work. Idempotent
338
342
  // (the `migrated` flag), so a no-op after the first call.
339
- const { tenant, partition } = this.getAttachment(ws);
340
- this.tenant = tenant;
341
- this.partition = partition;
343
+ const att = this.getAttachment(ws);
344
+ this.tenant = att.tenant;
345
+ this.partition = att.partition;
346
+
347
+ // The token was verified ONCE at upgrade; a live/hibernating socket can outlive its
348
+ // TTL. Re-check `exp` per message so an expired session can't keep calling/subscribing
349
+ // (role changes + the denylist only bite on reconnect, which this forces). Fail the
350
+ // frame and close 4401 so the client re-auths. Synthetic identities carry no exp.
351
+ if (this.isExpired(att.identity)) return this.rejectExpired(ws, msg.id);
352
+
342
353
  await this.ensureMigrated();
343
354
 
344
355
  switch (msg.type) {
@@ -417,6 +428,18 @@ export class PramenDOBase extends DurableObject<DoEnv> {
417
428
  for (const ws of this.ctx.getWebSockets()) {
418
429
  try {
419
430
  const att = this.getAttachment(ws);
431
+ // A live socket whose token expired while connected must not keep receiving pushes.
432
+ // Close it 4401 and drop its subscriptions instead of pushing (an expired hibernating
433
+ // socket already has no in-memory subs, so this only bites still-live ones).
434
+ if (this.isExpired(att.identity)) {
435
+ try {
436
+ ws.close(WS_CLOSE_UNAUTHORIZED, "session expired");
437
+ } catch {
438
+ /* already closing */
439
+ }
440
+ this.subsBySocket.delete(ws);
441
+ continue;
442
+ }
420
443
  const subs = this.getSubs(ws);
421
444
  let dirty = false;
422
445
  for (const sub of subs) {
@@ -604,6 +627,25 @@ export class PramenDOBase extends DurableObject<DoEnv> {
604
627
  }
605
628
  }
606
629
 
630
+ /** Has this socket's token expired? `exp` (epoch seconds) rides the socket attachment
631
+ * from the upgrade-time identity. A synthetic / non-expiring identity has no `exp` and is
632
+ * never expired, so callPrivileged and admin sockets keep working. */
633
+ private isExpired(identity: Identity | null): boolean {
634
+ const exp = identity?.exp;
635
+ return typeof exp === "number" && Date.now() / 1000 >= exp;
636
+ }
637
+
638
+ /** Reject a message from an expired socket: send the protocol's auth-failure error frame,
639
+ * then close 4401 (the client should re-authenticate and reconnect). */
640
+ private rejectExpired(ws: WebSocket, id: string): void {
641
+ this.send(ws, toWsError(id, new Unauthorized("session expired")));
642
+ try {
643
+ ws.close(WS_CLOSE_UNAUTHORIZED, "session expired");
644
+ } catch {
645
+ /* already closing */
646
+ }
647
+ }
648
+
607
649
  /** The durable per-socket auth/routing state (identity + tenant + partition), read
608
650
  * from the WS attachment. Survives hibernation; kept tiny to stay under workerd's cap. */
609
651
  private getAttachment(ws: WebSocket): SocketAttachment {
package/src/index.ts CHANGED
@@ -71,6 +71,9 @@ export type {
71
71
  WhereOps,
72
72
  } from "./sdk/infer";
73
73
 
74
+ // --- kv (ctx.kv) + session denylist (hard token revocation) ---
75
+ export { Kv, denySession, allowSession, isSessionDenied } from "./runtime/kv";
76
+
74
77
  // --- files ---
75
78
  export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } from "./sdk/files";
76
79
  export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
package/src/runtime/kv.ts CHANGED
@@ -45,3 +45,46 @@ export class Kv {
45
45
  };
46
46
  }
47
47
  }
48
+
49
+ // --- session denylist (hard token revocation) -------------------------------
50
+ //
51
+ // The core is stateless verify-only: roles/active are baked into a token at login and
52
+ // there is no per-request DB lookup. To revoke a token BEFORE its `exp` (deactivate /
53
+ // delete / compromise), we keep a tiny KV denylist keyed by username (= the JWT `sub`).
54
+ // The Worker checks it right after resolving identity — a denied `sub` fails closed
55
+ // (401), never silently downgrades to anonymous.
56
+ //
57
+ // The entry carries an `expirationTtl` equal to the session TTL, so it self-expires
58
+ // exactly when the last token that could have been outstanding at revocation time does:
59
+ // the list can only ever hold recently-revoked users and never grows unbounded. The key
60
+ // is username-scoped (not per-token), so it blocks EVERY token for that user — including
61
+ // a fresh login — which is why reactivation must lift it (see `allowSession`).
62
+
63
+ /** App-relative key (the `Kv` facade adds its own `app:` prefix). Writer (auth) and
64
+ * reader (Worker) both route through these helpers so the namespacing always agrees. */
65
+ const denyKey = (username: string): string => `authDenied:${username}`;
66
+
67
+ /** Cloudflare KV rejects an `expirationTtl` below 60s — clamp so a short session TTL
68
+ * still produces a valid (if slightly over-long) denylist entry. */
69
+ const MIN_KV_TTL_SECONDS = 60;
70
+
71
+ /** Revoke every outstanding token for `username` for up to `ttlSeconds` (the session
72
+ * TTL). Called when an account is deactivated / deleted. The entry self-expires, so the
73
+ * denylist never accumulates beyond the current revocation window. */
74
+ export async function denySession(kv: Kv, username: string, ttlSeconds: number): Promise<void> {
75
+ const ttl = Math.max(MIN_KV_TTL_SECONDS, Math.trunc(ttlSeconds) || MIN_KV_TTL_SECONDS);
76
+ await kv.put(denyKey(username), "1", { expirationTtl: ttl });
77
+ }
78
+
79
+ /** Lift a prior `denySession` (e.g. on reactivation). The key is username-scoped, so
80
+ * without this a reactivated account would stay locked out — even for a fresh login —
81
+ * until the denylist entry expired on its own. */
82
+ export async function allowSession(kv: Kv, username: string): Promise<void> {
83
+ await kv.delete(denyKey(username));
84
+ }
85
+
86
+ /** Is there a live denylist entry for `username`? The Worker consults this per
87
+ * authenticated request (only when a `sub` is present) to fail a revoked token closed. */
88
+ export async function isSessionDenied(kv: Kv, username: string): Promise<boolean> {
89
+ return (await kv.get(denyKey(username))) != null;
90
+ }
package/src/sdk/acl.ts CHANGED
@@ -12,6 +12,11 @@ export type Action = "read" | "create" | "update" | "delete";
12
12
  export interface Identity {
13
13
  role?: string;
14
14
  roles?: string[];
15
+ /** The verified token's `exp` (epoch seconds), when present. Carried so a long-lived
16
+ * WebSocket — whose identity is fixed at upgrade and never re-verified — can enforce
17
+ * expiry per message (see durable-object.ts). Absent for non-expiring / synthetic
18
+ * (callPrivileged) identities, which are therefore never treated as expired. */
19
+ exp?: number;
15
20
  [key: string]: unknown;
16
21
  }
17
22
 
package/src/worker.ts CHANGED
@@ -15,7 +15,7 @@ import { compileAcl } from "./runtime/acl";
15
15
  import { Db } from "./runtime/db";
16
16
  import { D1Driver, type D1SessionStart, type Driver } from "./runtime/driver";
17
17
  import { toResponse } from "./runtime/errors";
18
- import { Kv } from "./runtime/kv";
18
+ import { Kv, isSessionDenied } from "./runtime/kv";
19
19
  import { listDOs, partitionDoName } from "./runtime/registry";
20
20
  import { createFiles, handleFileRequest, handleMediaRequest, R2Adapter } from "./runtime/storage";
21
21
  import type { Identity } from "./sdk/acl";
@@ -312,6 +312,18 @@ export function makeWorker(app: PramenApp) {
312
312
 
313
313
  const identity = await resolveIdentity(req, strategyFor(env));
314
314
 
315
+ // Hard revocation (deactivate / delete / compromise), independent of token TTL: a
316
+ // revoked `sub` is on the KV denylist (written by @pramen/auth's setUserActive(false)
317
+ // / deleteUser, self-expiring at the session TTL). Check it HERE, at identity-resolution
318
+ // time — before the DO proxy AND the D1 store path, and before the admin routes — so it
319
+ // covers HTTP and the WebSocket upgrade alike. A denied token fails CLOSED (401); we do
320
+ // NOT silently downgrade to anonymous — a revoked user should see a clear auth failure.
321
+ // Synthetic identities (callPrivileged) never pass through here, so they're unaffected.
322
+ const sub = typeof identity?.userId === "string" ? identity.userId : undefined;
323
+ if (sub && (await isSessionDenied(new Kv(env.KV), sub))) {
324
+ return withCors(json({ ok: false, error: "session revoked", code: "unauthorized" }, 401), cors);
325
+ }
326
+
315
327
  // --- admin: list known (tenant, partition) DOs from the registry ---
316
328
  if (url.pathname === "/tenants") {
317
329
  if (!isAdmin(identity)) return withCors(forbidden("tenants"), cors);