@oh-my-pi/pi-ai 16.3.5 → 16.3.6

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.
@@ -16,13 +16,20 @@ import {
16
16
  type OAuthCredential,
17
17
  REMOTE_REFRESH_SENTINEL,
18
18
  type StoredAuthCredential,
19
+ type StoredCredentialBlock,
19
20
  } from "../auth-storage";
20
21
  import * as AIError from "../error";
21
22
  import type { OAuthCredentials } from "../registry/oauth/types";
22
23
  import type { Provider } from "../types";
23
24
  import type { UsageReport } from "../usage";
24
25
  import { type AuthBrokerClient, AuthBrokerStreamUnsupportedError } from "./client";
25
- import type { RefresherSchedule, SnapshotEntry, SnapshotResponse, SnapshotStreamEvent } from "./types";
26
+ import type {
27
+ CredentialBlockSnapshot,
28
+ RefresherSchedule,
29
+ SnapshotEntry,
30
+ SnapshotResponse,
31
+ SnapshotStreamEvent,
32
+ } from "./types";
26
33
 
27
34
  /**
28
35
  * Client-side TTL for the aggregate `/v1/usage` response. The broker dedups
@@ -38,6 +45,31 @@ const BACKGROUND_WAIT_MS = 30_000;
38
45
  const BACKGROUND_BACKOFF_INITIAL_MS = 500;
39
46
  const BACKGROUND_BACKOFF_MAX_MS = 30_000;
40
47
 
48
+ function compareCredentialBlockSnapshots(a: CredentialBlockSnapshot, b: CredentialBlockSnapshot): number {
49
+ const provider = a.providerKey.localeCompare(b.providerKey);
50
+ if (provider !== 0) return provider;
51
+ const scope = a.blockScope.localeCompare(b.blockScope);
52
+ if (scope !== 0) return scope;
53
+ return a.blockedUntilMs - b.blockedUntilMs;
54
+ }
55
+
56
+ function toCredentialBlockSnapshot(block: StoredCredentialBlock): CredentialBlockSnapshot {
57
+ return {
58
+ providerKey: block.providerKey,
59
+ blockScope: block.blockScope,
60
+ blockedUntilMs: block.blockedUntilMs,
61
+ };
62
+ }
63
+
64
+ function credentialEntryWithBlocks(
65
+ entry: AuthCredentialSnapshotEntry,
66
+ blocks: readonly CredentialBlockSnapshot[] | undefined,
67
+ ): SnapshotEntry {
68
+ const incoming: SnapshotEntry = { ...entry, rotatesInMs: null };
69
+ if (blocks && blocks.length > 0) incoming.blocks = [...blocks].sort(compareCredentialBlockSnapshots);
70
+ return incoming;
71
+ }
72
+
41
73
  function emptySnapshot(): SnapshotResponse {
42
74
  return {
43
75
  generation: 0,
@@ -169,13 +201,17 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
169
201
  }
170
202
 
171
203
  #applySnapshot(snapshot: SnapshotResponse, generation: number): void {
172
- this.#snapshot = snapshot;
204
+ const nowMs = Date.now();
205
+ this.#snapshot = {
206
+ ...snapshot,
207
+ credentials: snapshot.credentials.map(entry => this.#normalizeSnapshotEntryBlocks(entry, nowMs)),
208
+ };
173
209
  this.#generation = generation;
174
- this.#snapshotReceivedAt = Date.now();
210
+ this.#snapshotReceivedAt = nowMs;
175
211
  const onSnapshot = this.#onSnapshot;
176
212
  if (!onSnapshot) return;
177
213
  try {
178
- onSnapshot(snapshot, generation);
214
+ onSnapshot(this.#snapshot, generation);
179
215
  } catch (error) {
180
216
  logger.debug("auth-broker snapshot callback failed", { error: String(error) });
181
217
  }
@@ -266,11 +302,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
266
302
  generation: number,
267
303
  serverNowMs: number,
268
304
  ): void {
269
- const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === entry.id);
305
+ const incoming = this.#normalizeSnapshotEntryBlocks(entry, Date.now());
306
+ const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === incoming.id);
270
307
  const credentials =
271
308
  index === -1
272
- ? [...this.#snapshot.credentials, entry]
273
- : this.#snapshot.credentials.map((candidate, i) => (i === index ? entry : candidate));
309
+ ? [...this.#snapshot.credentials, incoming]
310
+ : this.#snapshot.credentials.map((candidate, i) => (i === index ? incoming : candidate));
274
311
  this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
275
312
  this.#generation = generation;
276
313
  this.#snapshotReceivedAt = Date.now();
@@ -304,6 +341,76 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
304
341
  return out;
305
342
  }
306
343
 
344
+ getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined {
345
+ const nowMs = Date.now();
346
+ this.cleanExpiredCredentialBlocks(nowMs);
347
+ const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
348
+ if (!entry?.blocks) return undefined;
349
+ const block = entry.blocks.find(
350
+ candidate => candidate.providerKey === providerKey && candidate.blockScope === blockScope,
351
+ );
352
+ if (!block || block.blockedUntilMs <= nowMs) return undefined;
353
+ return block.blockedUntilMs;
354
+ }
355
+
356
+ listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] {
357
+ const nowMs = Date.now();
358
+ this.cleanExpiredCredentialBlocks(nowMs);
359
+ const ids = new Set(credentialIds);
360
+ const blocks: StoredCredentialBlock[] = [];
361
+ for (const entry of this.#snapshot.credentials) {
362
+ if (!ids.has(entry.id) || !entry.blocks) continue;
363
+ for (const block of entry.blocks) {
364
+ if (block.blockedUntilMs <= nowMs) continue;
365
+ blocks.push({
366
+ credentialId: entry.id,
367
+ providerKey: block.providerKey,
368
+ blockScope: block.blockScope,
369
+ blockedUntilMs: block.blockedUntilMs,
370
+ });
371
+ }
372
+ }
373
+ blocks.sort((a, b) => a.credentialId - b.credentialId || compareCredentialBlockSnapshots(a, b));
374
+ return blocks;
375
+ }
376
+
377
+ upsertCredentialBlock(block: StoredCredentialBlock): void {
378
+ this.#upsertSnapshotBlock(block);
379
+ const body = toCredentialBlockSnapshot(block);
380
+ void this.#client
381
+ .upsertCredentialBlock(block.credentialId, body)
382
+ .then(() => {
383
+ this.#maybeRefreshSnapshot("credential block");
384
+ })
385
+ .catch(error => {
386
+ logger.warn("auth-broker credential block propagation failed", {
387
+ id: block.credentialId,
388
+ providerKey: block.providerKey,
389
+ blockScope: block.blockScope,
390
+ error: String(error),
391
+ });
392
+ });
393
+ }
394
+
395
+ deleteCredentialBlocks(credentialId: number): void {
396
+ this.#deleteSnapshotBlocks(credentialId);
397
+ void this.#client
398
+ .deleteCredentialBlocks(credentialId)
399
+ .then(() => {
400
+ this.#maybeRefreshSnapshot("credential blocks delete");
401
+ })
402
+ .catch(error => {
403
+ logger.warn("auth-broker credential blocks delete propagation failed", {
404
+ id: credentialId,
405
+ error: String(error),
406
+ });
407
+ });
408
+ }
409
+
410
+ cleanExpiredCredentialBlocks(nowMs: number): void {
411
+ this.#pruneExpiredCredentialBlocks(nowMs);
412
+ }
413
+
307
414
  /**
308
415
  * In-memory update from a successful refresh through the broker. AuthStorage
309
416
  * calls this after `#replaceCredentialAt`; the broker already persisted the
@@ -459,13 +566,19 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
459
566
  // `entries` is the broker's authoritative post-upsert list of rows for
460
567
  // `provider`. Drop our existing rows for the same provider and splice in
461
568
  // the fresh set — preserving every other provider's rows in place.
569
+ const existingBlocks = new Map(
570
+ this.#snapshot.credentials
571
+ .filter(entry => entry.provider === provider && entry.blocks !== undefined)
572
+ .map(entry => [entry.id, entry.blocks] as const),
573
+ );
462
574
  const others = this.#snapshot.credentials.filter(entry => entry.provider !== provider);
463
- const incoming = entries.map(entry => ({ ...entry, rotatesInMs: null }));
575
+ const incoming = entries.map(entry => credentialEntryWithBlocks(entry, existingBlocks.get(entry.id)));
464
576
  this.#snapshot = { ...this.#snapshot, credentials: [...others, ...incoming] };
465
577
  }
466
578
  #applyCredentialEntry(entry: AuthCredentialSnapshotEntry): void {
467
- const incoming = { ...entry, rotatesInMs: null };
468
579
  const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === entry.id);
580
+ const existingBlocks = index === -1 ? undefined : this.#snapshot.credentials[index]?.blocks;
581
+ const incoming = credentialEntryWithBlocks(entry, existingBlocks);
469
582
  if (index === -1) {
470
583
  this.#snapshot = { ...this.#snapshot, credentials: [...this.#snapshot.credentials, incoming] };
471
584
  return;
@@ -485,6 +598,73 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
485
598
  this.#snapshot = { ...this.#snapshot, credentials: next };
486
599
  }
487
600
 
601
+ #normalizeSnapshotEntryBlocks(entry: SnapshotEntry, nowMs: number): SnapshotEntry {
602
+ if (!entry.blocks || entry.blocks.length === 0) return entry;
603
+ const blocks = entry.blocks
604
+ .filter(block => block.blockedUntilMs > nowMs)
605
+ .map(block => ({
606
+ providerKey: block.providerKey,
607
+ blockScope: block.blockScope,
608
+ blockedUntilMs: block.blockedUntilMs,
609
+ }))
610
+ .sort(compareCredentialBlockSnapshots);
611
+ if (blocks.length > 0) return { ...entry, blocks };
612
+ const next: SnapshotEntry = { ...entry };
613
+ delete next.blocks;
614
+ return next;
615
+ }
616
+
617
+ #upsertSnapshotBlock(block: StoredCredentialBlock): void {
618
+ const index = this.#snapshot.credentials.findIndex(entry => entry.id === block.credentialId);
619
+ if (index === -1) return;
620
+ const entry = this.#snapshot.credentials[index]!;
621
+ const incoming = toCredentialBlockSnapshot(block);
622
+ const blocks = entry.blocks ? [...entry.blocks] : [];
623
+ const blockIndex = blocks.findIndex(
624
+ candidate => candidate.providerKey === incoming.providerKey && candidate.blockScope === incoming.blockScope,
625
+ );
626
+ if (blockIndex === -1) {
627
+ blocks.push(incoming);
628
+ } else {
629
+ const existing = blocks[blockIndex]!;
630
+ blocks[blockIndex] = {
631
+ ...existing,
632
+ blockedUntilMs: Math.max(existing.blockedUntilMs, incoming.blockedUntilMs),
633
+ };
634
+ }
635
+ blocks.sort(compareCredentialBlockSnapshots);
636
+ const credentials = [...this.#snapshot.credentials];
637
+ credentials[index] = { ...entry, blocks };
638
+ this.#snapshot = { ...this.#snapshot, credentials };
639
+ }
640
+
641
+ #deleteSnapshotBlocks(credentialId: number): void {
642
+ const index = this.#snapshot.credentials.findIndex(entry => entry.id === credentialId);
643
+ if (index === -1) return;
644
+ const entry = this.#snapshot.credentials[index]!;
645
+ if (!entry.blocks || entry.blocks.length === 0) return;
646
+ const next: SnapshotEntry = { ...entry };
647
+ delete next.blocks;
648
+ const credentials = [...this.#snapshot.credentials];
649
+ credentials[index] = next;
650
+ this.#snapshot = { ...this.#snapshot, credentials };
651
+ }
652
+
653
+ #pruneExpiredCredentialBlocks(nowMs: number): void {
654
+ let changed = false;
655
+ const credentials = this.#snapshot.credentials.map(entry => {
656
+ if (!entry.blocks || entry.blocks.length === 0) return entry;
657
+ const blocks = entry.blocks.filter(block => block.blockedUntilMs > nowMs);
658
+ if (blocks.length === entry.blocks.length) return entry;
659
+ changed = true;
660
+ if (blocks.length > 0) return { ...entry, blocks };
661
+ const next: SnapshotEntry = { ...entry };
662
+ delete next.blocks;
663
+ return next;
664
+ });
665
+ if (changed) this.#snapshot = { ...this.#snapshot, credentials };
666
+ }
667
+
488
668
  /**
489
669
  * Fire-and-forget `refreshSnapshot()` after a write. When the SSE stream is
490
670
  * active the broker will deliver the new generation push, so the extra GET
@@ -11,10 +11,13 @@
11
11
  */
12
12
  import { logger } from "@oh-my-pi/pi-utils";
13
13
  import { type Type, type } from "arktype";
14
- import type { AuthStorage } from "../auth-storage";
14
+ import type { AuthStorage, StoredCredentialBlock } from "../auth-storage";
15
15
  import { parseBind } from "../utils/parse-bind";
16
16
  import { AuthBrokerRefresher, type AuthBrokerRefresherSchedule } from "./refresher";
17
17
  import type {
18
+ CredentialBlockResponse,
19
+ CredentialBlockSnapshot,
20
+ CredentialBlocksDeleteResponse,
18
21
  CredentialDisableResponse,
19
22
  CredentialRefreshResponse,
20
23
  CredentialUploadResponse,
@@ -33,7 +36,11 @@ import {
33
36
  DEFAULT_SERVER_IDLE_TIMEOUT_S,
34
37
  DEFAULT_STREAM_KEEPALIVE_MS,
35
38
  } from "./types";
36
- import { credentialDisableRequestSchema, credentialUploadRequestSchema } from "./wire-schemas";
39
+ import {
40
+ credentialBlockRequestSchema,
41
+ credentialDisableRequestSchema,
42
+ credentialUploadRequestSchema,
43
+ } from "./wire-schemas";
37
44
 
38
45
  export interface AuthBrokerServerOptions {
39
46
  /** Underlying credential storage (wraps the local SQLite store on the broker). */
@@ -120,6 +127,8 @@ async function parseBody<t>(
120
127
 
121
128
  const REFRESH_ROUTE = /^\/v1\/credential\/(\d+)\/refresh$/;
122
129
  const DISABLE_ROUTE = /^\/v1\/credential\/(\d+)\/disable$/;
130
+ const BLOCK_ROUTE = /^\/v1\/credential\/(\d+)\/block$/;
131
+ const BLOCKS_ROUTE = /^\/v1\/credential\/(\d+)\/blocks$/;
123
132
 
124
133
  const MAX_SNAPSHOT_WAIT_MS = 30_000;
125
134
  const DISABLED_NEXT_SWEEP_IN_MS = Number.MAX_SAFE_INTEGER;
@@ -262,14 +271,48 @@ function computeRotatesInMs(
262
271
  return Math.max(0, rotatesAt - serverNowMs);
263
272
  }
264
273
 
274
+ function compareCredentialBlockSnapshots(a: CredentialBlockSnapshot, b: CredentialBlockSnapshot): number {
275
+ const provider = a.providerKey.localeCompare(b.providerKey);
276
+ if (provider !== 0) return provider;
277
+ const scope = a.blockScope.localeCompare(b.blockScope);
278
+ if (scope !== 0) return scope;
279
+ return a.blockedUntilMs - b.blockedUntilMs;
280
+ }
281
+
282
+ function buildCredentialBlockGroups(
283
+ blocks: readonly StoredCredentialBlock[],
284
+ serverNowMs: number,
285
+ ): Map<number, CredentialBlockSnapshot[]> {
286
+ const byCredentialId = new Map<number, CredentialBlockSnapshot[]>();
287
+ for (const block of blocks) {
288
+ if (block.blockedUntilMs <= serverNowMs) continue;
289
+ const snapshotBlock: CredentialBlockSnapshot = {
290
+ providerKey: block.providerKey,
291
+ blockScope: block.blockScope,
292
+ blockedUntilMs: block.blockedUntilMs,
293
+ };
294
+ const existing = byCredentialId.get(block.credentialId);
295
+ if (existing) {
296
+ existing.push(snapshotBlock);
297
+ } else {
298
+ byCredentialId.set(block.credentialId, [snapshotBlock]);
299
+ }
300
+ }
301
+ for (const credentialBlocks of byCredentialId.values()) credentialBlocks.sort(compareCredentialBlockSnapshots);
302
+ return byCredentialId;
303
+ }
304
+
265
305
  function buildSnapshot(storage: AuthStorage, refresher: AuthBrokerRefresher | undefined): SnapshotResponse {
266
306
  const serverNowMs = Date.now();
267
307
  const base = storage.exportSnapshot();
268
308
  const { wire, nextSweepAt } = resolveRefresherSchedule(refresher, serverNowMs);
269
- const credentials: SnapshotEntry[] = base.credentials.map(entry => ({
270
- ...entry,
271
- rotatesInMs: computeRotatesInMs(entry, wire, nextSweepAt, serverNowMs),
272
- }));
309
+ const credentialIds = base.credentials.map(entry => entry.id);
310
+ const blocksByCredentialId = buildCredentialBlockGroups(storage.listCredentialBlocks(credentialIds), serverNowMs);
311
+ const credentials: SnapshotEntry[] = base.credentials.map(entry => {
312
+ const blocks = blocksByCredentialId.get(entry.id);
313
+ const rotatesInMs = computeRotatesInMs(entry, wire, nextSweepAt, serverNowMs);
314
+ return blocks && blocks.length > 0 ? { ...entry, rotatesInMs, blocks } : { ...entry, rotatesInMs };
315
+ });
273
316
  return {
274
317
  generation: base.generation,
275
318
  generatedAt: base.generatedAt,
@@ -336,7 +379,14 @@ async function serveSnapshot(
336
379
  * keep the stale projection.
337
380
  */
338
381
  function fingerprintEntry(entry: SnapshotEntry): string {
339
- return JSON.stringify([entry.id, entry.provider, entry.identityKey, entry.rotatesInMs, entry.credential]);
382
+ return JSON.stringify([
383
+ entry.id,
384
+ entry.provider,
385
+ entry.identityKey,
386
+ entry.rotatesInMs,
387
+ entry.credential,
388
+ entry.blocks ?? [],
389
+ ]);
340
390
  }
341
391
 
342
392
  function sseEvent(event: string, body: unknown): string {
@@ -593,6 +643,58 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
593
643
  const response: CredentialDisableResponse = { ok: true };
594
644
  return json(200, response);
595
645
  }
646
+ const blockMatch = req.method === "POST" ? pathname.match(BLOCK_ROUTE) : null;
647
+ if (blockMatch) {
648
+ const id = Number.parseInt(blockMatch[1], 10);
649
+ const parsed = await parseBody(req, credentialBlockRequestSchema);
650
+ if (!parsed.ok) return parsed.response;
651
+ const block: StoredCredentialBlock = {
652
+ credentialId: id,
653
+ providerKey: parsed.data.providerKey,
654
+ blockScope: parsed.data.blockScope,
655
+ blockedUntilMs: parsed.data.blockedUntilMs,
656
+ };
657
+ if (!opts.storage.exportSnapshot().credentials.some(entry => entry.id === id)) {
658
+ logger.info("auth-broker credential block miss", { id, peer });
659
+ return json(404, { error: `No credential with id=${id}` });
660
+ }
661
+ try {
662
+ opts.storage.upsertCredentialBlock(block);
663
+ const response: CredentialBlockResponse = { ok: true };
664
+ logger.info("auth-broker credential block upserted", {
665
+ id,
666
+ peer,
667
+ providerKey: block.providerKey,
668
+ blockScope: block.blockScope,
669
+ blockedUntilMs: block.blockedUntilMs,
670
+ });
671
+ return json(200, response);
672
+ } catch (error) {
673
+ const message = error instanceof Error ? error.message : String(error);
674
+ logger.warn("auth-broker credential block upsert failed", { id, peer, error: message });
675
+ const status = message.includes("No credential with id") ? 404 : 500;
676
+ return json(status, { error: message });
677
+ }
678
+ }
679
+ const blocksDeleteMatch = req.method === "DELETE" ? pathname.match(BLOCKS_ROUTE) : null;
680
+ if (blocksDeleteMatch) {
681
+ const id = Number.parseInt(blocksDeleteMatch[1], 10);
682
+ if (!opts.storage.exportSnapshot().credentials.some(entry => entry.id === id)) {
683
+ logger.info("auth-broker credential blocks delete miss", { id, peer });
684
+ return json(404, { error: `No credential with id=${id}` });
685
+ }
686
+ try {
687
+ opts.storage.deleteCredentialBlocks(id);
688
+ const response: CredentialBlocksDeleteResponse = { ok: true };
689
+ logger.info("auth-broker credential blocks deleted", { id, peer });
690
+ return json(200, response);
691
+ } catch (error) {
692
+ const message = error instanceof Error ? error.message : String(error);
693
+ logger.warn("auth-broker credential blocks delete failed", { id, peer, error: message });
694
+ const status = message.includes("No credential with id") ? 404 : 500;
695
+ return json(status, { error: message });
696
+ }
697
+ }
596
698
  if (req.method === "POST" && pathname === "/v1/credential") {
597
699
  const parsed = await parseBody(req, credentialUploadRequestSchema);
598
700
  if (!parsed.ok) return parsed.response;
@@ -6,7 +6,12 @@
6
6
  * credential expires or a 401 surfaces on a supposedly-fresh credential.
7
7
  */
8
8
 
9
- import type { AuthCredential, AuthCredentialSnapshot, AuthCredentialSnapshotEntry } from "../auth-storage";
9
+ import type {
10
+ AuthCredential,
11
+ AuthCredentialSnapshot,
12
+ AuthCredentialSnapshotEntry,
13
+ StoredCredentialBlock,
14
+ } from "../auth-storage";
10
15
  import type { UsageReport } from "../usage";
11
16
 
12
17
  /** GET /v1/healthz response body. */
@@ -22,8 +27,11 @@ export interface RefresherSchedule {
22
27
  nextSweepInMs: number;
23
28
  }
24
29
 
30
+ export type CredentialBlockSnapshot = Omit<StoredCredentialBlock, "credentialId">;
31
+
25
32
  export type SnapshotEntry = AuthCredentialSnapshotEntry & {
26
33
  rotatesInMs: number | null;
34
+ blocks?: CredentialBlockSnapshot[];
27
35
  };
28
36
 
29
37
  /** GET /v1/snapshot response body. */
@@ -54,6 +62,19 @@ export interface CredentialDisableResponse {
54
62
  ok: boolean;
55
63
  }
56
64
 
65
+ /** POST /v1/credential/:id/block request body. */
66
+ export type CredentialBlockRequest = CredentialBlockSnapshot;
67
+
68
+ /** POST /v1/credential/:id/block response body. */
69
+ export interface CredentialBlockResponse {
70
+ ok: boolean;
71
+ }
72
+
73
+ /** DELETE /v1/credential/:id/blocks response body. */
74
+ export interface CredentialBlocksDeleteResponse {
75
+ ok: boolean;
76
+ }
77
+
57
78
  /**
58
79
  * POST /v1/credential request body. The OAuth `refresh` must be the *real*
59
80
  * refresh token (not the sentinel) — the broker is the canonical writer.
@@ -69,6 +69,13 @@ export const credentialSnapshotEntrySchema = type({
69
69
  identityKey: "string | null",
70
70
  });
71
71
 
72
+ export const credentialBlockSnapshotSchema = type({
73
+ "+": "reject",
74
+ providerKey: type("string").atLeastLength(1),
75
+ blockScope: "string",
76
+ blockedUntilMs: "number",
77
+ });
78
+
72
79
  export const snapshotEntrySchema = type({
73
80
  "+": "reject",
74
81
  id: "number.integer",
@@ -76,6 +83,7 @@ export const snapshotEntrySchema = type({
76
83
  credential: snapshotCredentialSchema,
77
84
  identityKey: "string | null",
78
85
  rotatesInMs: "number | null",
86
+ "blocks?": credentialBlockSnapshotSchema.array(),
79
87
  });
80
88
 
81
89
  export const refresherScheduleSchema = type({
@@ -235,6 +243,20 @@ export const credentialDisableResponseSchema = type({
235
243
  ok: "boolean",
236
244
  });
237
245
 
246
+ // ─── Credential blocks ──────────────────────────────────────────────────────
247
+
248
+ export const credentialBlockRequestSchema = credentialBlockSnapshotSchema;
249
+
250
+ export const credentialBlockResponseSchema = type({
251
+ "+": "reject",
252
+ ok: "boolean",
253
+ });
254
+
255
+ export const credentialBlocksDeleteResponseSchema = type({
256
+ "+": "reject",
257
+ ok: "boolean",
258
+ });
259
+
238
260
  // ─── Upload ────────────────────────────────────────────────────────────────
239
261
 
240
262
  export const credentialUploadRequestSchema = type({