@tempoxyz/id 0.0.1 → 0.0.2

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.
Files changed (120) hide show
  1. package/dist/app/server/.vite/manifest.json +65 -65
  2. package/dist/app/server/assets/Grant-D_tun0mF.js.map +1 -1
  3. package/dist/app/server/assets/authorize-DXYo-K2n-B-6vqhvc.js +56 -0
  4. package/dist/app/server/assets/authorize-DXYo-K2n-B-6vqhvc.js.map +1 -0
  5. package/dist/app/server/assets/core-FSS9-r35.js +56 -0
  6. package/dist/app/server/assets/core-FSS9-r35.js.map +1 -0
  7. package/dist/app/server/assets/default-u_dwuiYb-Bby14ucv.js +2 -0
  8. package/dist/app/server/assets/{default-u_dwuiYb-DOrMMcg9.js.map → default-u_dwuiYb-Bby14ucv.js.map} +1 -1
  9. package/dist/app/server/assets/{dist-CoH0hD1s.js → dist-B9KiBJYw.js} +2 -2
  10. package/dist/app/server/assets/{dist-CoH0hD1s.js.map → dist-B9KiBJYw.js.map} +1 -1
  11. package/dist/app/server/assets/{dist-DcICI3fd.js → dist-CU1NXSiz.js} +2 -2
  12. package/dist/app/server/assets/{dist-DcICI3fd.js.map → dist-CU1NXSiz.js.map} +1 -1
  13. package/dist/app/server/assets/dist-CetH4JSY.js +1 -0
  14. package/dist/app/server/assets/{effect-QlVUlMFu-mXPkuQTb.js → effect-QlVUlMFu-Cm-eTtaO.js} +2 -2
  15. package/dist/app/server/assets/{effect-QlVUlMFu-mXPkuQTb.js.map → effect-QlVUlMFu-Cm-eTtaO.js.map} +1 -1
  16. package/dist/app/server/assets/factory-g3i_Ffi5.js +5 -0
  17. package/dist/app/server/assets/{factory-dvoOlKer.js.map → factory-g3i_Ffi5.js.map} +1 -1
  18. package/dist/app/server/assets/get-id-field-BpOAoqqI.js +6 -0
  19. package/dist/app/server/assets/get-id-field-BpOAoqqI.js.map +1 -0
  20. package/dist/app/server/assets/{index-CLddUTqr-CC33CMvS.js → index-CLddUTqr-CmgkbWi9.js} +2 -2
  21. package/dist/app/server/assets/{index-CLddUTqr-CC33CMvS.js.map → index-CLddUTqr-CmgkbWi9.js.map} +1 -1
  22. package/dist/app/server/assets/{index-DZEfthgZ-Ec2MMQ_e.js → index-DZEfthgZ-BnPmps9O.js} +2 -2
  23. package/dist/app/server/assets/{index-DZEfthgZ-Ec2MMQ_e.js.map → index-DZEfthgZ-BnPmps9O.js.map} +1 -1
  24. package/dist/app/server/assets/kysely-adapter-B8VoPArC.js +1 -0
  25. package/dist/app/server/assets/{schema-check-oLqvHR8y.js → schema-check-Bgwl1v0_.js} +2 -2
  26. package/dist/app/server/assets/{schema-check-oLqvHR8y.js.map → schema-check-Bgwl1v0_.js.map} +1 -1
  27. package/dist/app/server/assets/{sury-CWZTCd75-CDaOS2oM.js → sury-CWZTCd75-Bd_PzqCV.js} +2 -2
  28. package/dist/app/server/assets/{sury-CWZTCd75-CDaOS2oM.js.map → sury-CWZTCd75-Bd_PzqCV.js.map} +1 -1
  29. package/dist/app/server/assets/{valibot--1zFm7rT-CIKoH3MS.js → valibot--1zFm7rT-Dd2aEhWG.js} +2 -2
  30. package/dist/app/server/assets/{valibot--1zFm7rT-CIKoH3MS.js.map → valibot--1zFm7rT-Dd2aEhWG.js.map} +1 -1
  31. package/dist/app/server/assets/{valibot-D_HTw1Gn-BdPyLkXt.js → valibot-D_HTw1Gn-C1l3va6F.js} +2 -2
  32. package/dist/app/server/assets/{valibot-D_HTw1Gn-BdPyLkXt.js.map → valibot-D_HTw1Gn-C1l3va6F.js.map} +1 -1
  33. package/dist/app/server/assets/{zod-Bwrt9trS-BezI5Gi1.js → zod-Bwrt9trS-Dry2w0in.js} +2 -2
  34. package/dist/app/server/assets/{zod-Bwrt9trS-BezI5Gi1.js.map → zod-Bwrt9trS-Dry2w0in.js.map} +1 -1
  35. package/dist/app/server/assets/{zod-DSgpEGAE-DZ7M_Q33.js → zod-DSgpEGAE-BZMP86UA.js} +2 -2
  36. package/dist/app/server/assets/{zod-DSgpEGAE-DZ7M_Q33.js.map → zod-DSgpEGAE-BZMP86UA.js.map} +1 -1
  37. package/dist/app/server/index.js +35 -35
  38. package/dist/app/server/index.js.map +1 -1
  39. package/dist/client/Auth.d.ts +1 -1
  40. package/dist/client/Auth.d.ts.map +1 -1
  41. package/dist/client/Auth.js.map +1 -1
  42. package/dist/client/Decorator.d.ts +3 -1
  43. package/dist/client/Decorator.d.ts.map +1 -1
  44. package/dist/client/Decorator.js +1 -0
  45. package/dist/client/Decorator.js.map +1 -1
  46. package/dist/client/actions/passkey.d.ts +63 -2
  47. package/dist/client/actions/passkey.d.ts.map +1 -1
  48. package/dist/client/actions/passkey.js +121 -3
  49. package/dist/client/actions/passkey.js.map +1 -1
  50. package/dist/internal/Apps.d.ts +14 -0
  51. package/dist/internal/Apps.d.ts.map +1 -1
  52. package/dist/internal/Apps.js +14 -2
  53. package/dist/internal/Apps.js.map +1 -1
  54. package/dist/internal/Auth.d.ts +7 -0
  55. package/dist/internal/Auth.d.ts.map +1 -1
  56. package/dist/internal/Auth.js +2 -1
  57. package/dist/internal/Auth.js.map +1 -1
  58. package/dist/internal/Authorize.d.ts.map +1 -1
  59. package/dist/internal/Authorize.js +13 -13
  60. package/dist/internal/Authorize.js.map +1 -1
  61. package/dist/internal/ErrorResponse.d.ts +5 -0
  62. package/dist/internal/ErrorResponse.d.ts.map +1 -1
  63. package/dist/internal/ErrorResponse.js +8 -0
  64. package/dist/internal/ErrorResponse.js.map +1 -1
  65. package/dist/internal/KeyAuthorization.d.ts.map +1 -1
  66. package/dist/internal/KeyAuthorization.js +28 -28
  67. package/dist/internal/KeyAuthorization.js.map +1 -1
  68. package/dist/internal/Owner.d.ts +16 -0
  69. package/dist/internal/Owner.d.ts.map +1 -1
  70. package/dist/internal/Owner.js.map +1 -1
  71. package/dist/internal/Passkey.d.ts +66 -4
  72. package/dist/internal/Passkey.d.ts.map +1 -1
  73. package/dist/internal/Passkey.js +66 -30
  74. package/dist/internal/Passkey.js.map +1 -1
  75. package/dist/internal/Privy.d.ts +27 -0
  76. package/dist/internal/Privy.d.ts.map +1 -1
  77. package/dist/internal/Privy.js +82 -0
  78. package/dist/internal/Privy.js.map +1 -1
  79. package/dist/internal/TempoAccount.d.ts +57 -0
  80. package/dist/internal/TempoAccount.d.ts.map +1 -1
  81. package/dist/internal/TempoAccount.js +118 -5
  82. package/dist/internal/TempoAccount.js.map +1 -1
  83. package/dist/internal/owners/Privy.d.ts.map +1 -1
  84. package/dist/internal/owners/Privy.js +11 -2
  85. package/dist/internal/owners/Privy.js.map +1 -1
  86. package/package.json +1 -1
  87. package/src/api/account.ts +140 -103
  88. package/src/api/authenticate.ts +48 -51
  89. package/src/api/authorize.ts +35 -36
  90. package/src/api/device.ts +20 -23
  91. package/src/api/health.ts +1 -2
  92. package/src/api/lib/AccountStep.ts +75 -0
  93. package/src/api/lib/Auth.ts +6 -0
  94. package/src/api/lib/Cors.ts +3 -0
  95. package/src/api/lib/KeySigner.ts +1 -1
  96. package/src/api/oauth.ts +23 -22
  97. package/src/client/Auth.ts +1 -0
  98. package/src/client/Decorator.ts +6 -1
  99. package/src/client/actions/passkey.ts +197 -3
  100. package/src/internal/Apps.ts +26 -2
  101. package/src/internal/Auth.ts +4 -0
  102. package/src/internal/Authorize.ts +13 -15
  103. package/src/internal/ErrorResponse.ts +10 -0
  104. package/src/internal/KeyAuthorization.ts +28 -31
  105. package/src/internal/OpenApi.ts +35 -0
  106. package/src/internal/Owner.ts +16 -0
  107. package/src/internal/Passkey.ts +99 -34
  108. package/src/internal/Privy.ts +128 -0
  109. package/src/internal/TempoAccount.ts +221 -7
  110. package/src/internal/owners/Privy.ts +19 -6
  111. package/dist/app/server/assets/authorize-DXYo-K2n-rmQQ42x1.js +0 -56
  112. package/dist/app/server/assets/authorize-DXYo-K2n-rmQQ42x1.js.map +0 -1
  113. package/dist/app/server/assets/core-BBrZXfrB.js +0 -56
  114. package/dist/app/server/assets/core-BBrZXfrB.js.map +0 -1
  115. package/dist/app/server/assets/default-u_dwuiYb-DOrMMcg9.js +0 -2
  116. package/dist/app/server/assets/dist-CR6FW-yS.js +0 -1
  117. package/dist/app/server/assets/factory-dvoOlKer.js +0 -5
  118. package/dist/app/server/assets/get-id-field-OwLpfjj2.js +0 -6
  119. package/dist/app/server/assets/get-id-field-OwLpfjj2.js.map +0 -1
  120. package/dist/app/server/assets/kysely-adapter-B8tQwTcu.js +0 -1
@@ -17,7 +17,7 @@ import { Address, Base64, Hash, Hex, P256, PublicKey } from 'ox'
17
17
  import { Authentication, Registration } from 'webauthx/server'
18
18
  import { z } from 'zod'
19
19
  import * as Schema from '../app/db/schema.js'
20
- import type * as Apps from './Apps.js'
20
+ import * as Apps from './Apps.js'
21
21
  import * as Authorize from './Authorize.js'
22
22
  import * as ErrorResponse from './ErrorResponse.js'
23
23
  import * as KeyAuthorization from './KeyAuthorization.js'
@@ -57,8 +57,8 @@ export class LinkExpiredError extends ErrorResponse.define(
57
57
  }
58
58
 
59
59
  /**
60
- * A passkey sign-in names an app, or reaches a realm, that holds no passkeys for Tempo ID, or no
61
- * passkeys whose relying party covers the app's page.
60
+ * A passkey sign-in or registration names an app, or reaches a realm, that holds no passkeys for
61
+ * Tempo ID, or no passkeys whose relying party covers the app's page.
62
62
  */
63
63
  export class NotPermittedError extends ErrorResponse.define(
64
64
  'passkey_not_permitted',
@@ -79,6 +79,12 @@ export class NotPermittedError extends ErrorResponse.define(
79
79
  error_description: `Passkey not permitted: the app ${clientId} holds no passkeys. Sign in with an emailed code.`,
80
80
  meta: { client_id: clientId },
81
81
  }
82
+ // A registration names no app, only the page that runs it.
83
+ if (origin)
84
+ return {
85
+ error_description: `Passkey not permitted: no realm holds passkeys for ${origin}. Serve the page on the domain of the relying party, or one of its subdomains.`,
86
+ meta: { origin },
87
+ }
82
88
  return {
83
89
  error_description:
84
90
  'Passkey not permitted: no realm holds passkeys for this sign-in. Sign in with an emailed code.',
@@ -113,6 +119,32 @@ export class ReauthenticationRequiredError extends ErrorResponse.define(
113
119
  }
114
120
  }
115
121
 
122
+ /**
123
+ * A passkey registration comes from an app's page, and no app at the page's origin holds the
124
+ * `tempo-id:sign` scope. A registered passkey signs in as the user, so only an app that the
125
+ * deployment trusts with its own challenges registers one.
126
+ */
127
+ export class RegisterNotPermittedError extends ErrorResponse.define(
128
+ 'passkey_register_not_permitted',
129
+ ) {
130
+ override readonly name = 'Passkey.RegisterNotPermittedError'
131
+
132
+ constructor(options: RegisterNotPermittedError.Options) {
133
+ const { origin } = options
134
+ super({
135
+ error_description: `Passkey registration not permitted: no app at ${origin} holds the \`${Apps.signScope}\` scope. Register the passkey on Tempo ID, or grant the app the scope.`,
136
+ meta: { origin, scope: Apps.signScope },
137
+ })
138
+ }
139
+ }
140
+
141
+ export declare namespace RegisterNotPermittedError {
142
+ type Options = {
143
+ /** The origin of the app's page that sent the request. */
144
+ origin: string
145
+ }
146
+ }
147
+
116
148
  /**
117
149
  * A passkey ceremony failed: its challenge is unknown, spent, or expired, or the response does not
118
150
  * verify. One refusal covers every case, so it reveals neither which check failed nor whether a
@@ -186,6 +218,10 @@ export type ProofChallenge = {
186
218
  challenge: Hex.Hex
187
219
  /** The credential ID of the new passkey, base64url. */
188
220
  id: string
221
+ /**
222
+ * The origin of the app's page that the ceremony runs at, or `undefined` for Tempo ID's own page.
223
+ */
224
+ origin: string | undefined
189
225
  /** The public key that the attestation holds, which the proof must verify against. */
190
226
  publicKey: Hex.Hex
191
227
  /** The relying party of the `default` realm, which the new passkey belongs to. */
@@ -196,6 +232,10 @@ export type ProofChallenge = {
196
232
  export type RegistrationChallenge = {
197
233
  /** The challenge, as hex. */
198
234
  challenge: Hex.Hex
235
+ /**
236
+ * The origin of the app's page that the ceremony runs at, or `undefined` for Tempo ID's own page.
237
+ */
238
+ origin: string | undefined
199
239
  /** The relying party of the `default` realm, which the new passkey must belong to. */
200
240
  rpId: string
201
241
  }
@@ -311,7 +351,7 @@ export namespace schema {
311
351
  .optional()
312
352
  .meta({
313
353
  description:
314
- 'The character offset of `"challenge"` in `clientDataJSON`. Absent, verification searches for it.',
354
+ 'The character offset of `"challenge"` in `clientDataJSON`. Absent to search for it.',
315
355
  examples: [23],
316
356
  }),
317
357
  clientDataJSON: z
@@ -331,7 +371,7 @@ export namespace schema {
331
371
  .optional()
332
372
  .meta({
333
373
  description:
334
- 'The character offset of `"type"` in `clientDataJSON`. Absent, verification searches for it.',
374
+ 'The character offset of `"type"` in `clientDataJSON`. Absent to search for it.',
335
375
  examples: [1],
336
376
  }),
337
377
  userVerificationRequired: z
@@ -339,13 +379,12 @@ export namespace schema {
339
379
  .optional()
340
380
  .meta({
341
381
  description:
342
- 'Whether the client required user verification. Verification ignores it and always requires user verification.',
382
+ 'Whether the client required user verification. Ignored, since Tempo ID always requires it.',
343
383
  examples: [true],
344
384
  }),
345
385
  })
346
386
  .meta({
347
- description:
348
- 'The signed data that verification reads with `signature`, decoded from the browser response.',
387
+ description: "The signed data, decoded from the browser's response.",
349
388
  }),
350
389
  raw: z
351
390
  .object({
@@ -355,7 +394,7 @@ export namespace schema {
355
394
  .default(null)
356
395
  .meta({
357
396
  description:
358
- 'How the authenticator attaches to the client, such as `platform` or `cross-platform`. Defaults to `null` when the browser reports none.',
397
+ 'How the authenticator connects, such as `platform` or `cross-platform`, or `null`.',
359
398
  examples: ['platform'],
360
399
  }),
361
400
  id: base64url.meta({
@@ -379,7 +418,7 @@ export namespace schema {
379
418
  })
380
419
  .meta({
381
420
  description:
382
- "The authenticator's response. Verification keeps only `clientDataJSON` and drops other fields.",
421
+ "The authenticator's response, of which Tempo ID reads only `clientDataJSON`.",
383
422
  }),
384
423
  type: z.string().meta({
385
424
  description: 'The credential type.',
@@ -399,7 +438,8 @@ export namespace schema {
399
438
  }),
400
439
  })
401
440
  .meta({
402
- description: 'The assertion, serialized as `webauthx/client` returns it.',
441
+ description:
442
+ 'The result of `navigator.credentials.get()`, as `webauthx/client` serializes it.',
403
443
  id: 'PasskeyAssertion',
404
444
  })
405
445
 
@@ -433,7 +473,7 @@ export namespace schema {
433
473
  .templateLiteral(['0x', z.string().regex(/^04[0-9a-fA-F]{128}$/)])
434
474
  .meta({
435
475
  description:
436
- "The new passkey's uncompressed P256 public key, as hex. Verification reads the key from `attestationObject` and ignores this copy.",
476
+ "The new passkey's uncompressed P256 public key, as hex. Ignored, since Tempo ID reads the key from `attestationObject`.",
437
477
  examples: [
438
478
  '0x04f6310589abb21116acc6e123967ed0a125b5041c43f8e157237093e07eaa354c6c3f1e0a9b7d24f58e1c3a6b9d0f2e4a7c5b8d1e3f6a9c2b4d7e0f1a3c5b8d',
439
479
  ],
@@ -446,7 +486,7 @@ export namespace schema {
446
486
  .default(null)
447
487
  .meta({
448
488
  description:
449
- 'How the authenticator attaches to the client, such as `platform` or `cross-platform`. Defaults to `null` when the browser reports none.',
489
+ 'How the authenticator connects, such as `platform` or `cross-platform`, or `null`.',
450
490
  examples: ['platform'],
451
491
  }),
452
492
  id: base64url.max(1364).meta({
@@ -471,7 +511,7 @@ export namespace schema {
471
511
  })
472
512
  .meta({
473
513
  description:
474
- "The authenticator's response. Verification keeps only `clientDataJSON`, and reads the top-level `attestationObject`.",
514
+ "The authenticator's response, of which Tempo ID reads only `clientDataJSON`.",
475
515
  }),
476
516
  type: z
477
517
  .string()
@@ -488,7 +528,7 @@ export namespace schema {
488
528
  })
489
529
  .meta({
490
530
  description:
491
- 'The new passkey, serialized as `webauthx/client` returns it.',
531
+ 'The result of `navigator.credentials.create()`, as `webauthx/client` serializes it.',
492
532
  id: 'PasskeyCredential',
493
533
  })
494
534
 
@@ -510,12 +550,16 @@ export namespace schema {
510
550
  /** A proof challenge's `verification` value. */
511
551
  export const proof = z.object({
512
552
  id: z.string(),
553
+ // A challenge without an origin runs at Tempo ID's own page.
554
+ origin: z.string().optional(),
513
555
  publicKey: hex,
514
556
  rpId: z.string(),
515
557
  })
516
558
 
517
559
  /** A registration challenge's `verification` value. */
518
560
  export const registration = z.object({
561
+ // A challenge without an origin runs at Tempo ID's own page.
562
+ origin: z.string().optional(),
519
563
  rpId: z.string(),
520
564
  })
521
565
 
@@ -558,11 +602,11 @@ export namespace schema {
558
602
  )
559
603
  .meta({
560
604
  description:
561
- "The account's passkey, the only one the browser offers.",
605
+ "The account's passkey, which is the only passkey that the browser offers.",
562
606
  }),
563
607
  challenge: z.templateLiteral(['0x', z.string()]).meta({
564
608
  description:
565
- "The key authorization's digest, as hex. It is single-use, and expires after 5 minutes.",
609
+ "The key authorization's digest, as hex, which expires after 5 minutes.",
566
610
  examples: [
567
611
  '0xf6310589abb21116acc6e123967ed0a125b5041c43f8e157237093e07eaa354c',
568
612
  ],
@@ -574,30 +618,29 @@ export namespace schema {
574
618
  }),
575
619
  userVerification: z.literal('required').meta({
576
620
  description:
577
- 'Every passkey signature requires user verification.',
621
+ 'Requires user verification, such as a biometric or PIN.',
578
622
  }),
579
623
  })
580
624
  .meta({
581
625
  description:
582
- 'The `PublicKeyCredentialRequestOptions`, serialized with the challenge as hex.',
626
+ 'The `PublicKeyCredentialRequestOptions`, with the challenge as hex.',
583
627
  }),
584
628
  })
585
629
  .meta({
586
- description: 'The options for `navigator.credentials.get()`.',
630
+ description: 'The argument for `navigator.credentials.get()`.',
587
631
  }),
588
632
  type: z.literal('passkey').meta({
589
633
  description:
590
- 'Sign the options with the passkey, then repeat the approval with `passkey: { assertion }`.',
634
+ 'Sign the challenge with the passkey, then repeat the request with `passkey: { assertion }`.',
591
635
  }),
592
636
  })
593
637
  .meta({
594
638
  description:
595
- "The next step: the passkey's signature over the key authorization's digest.",
639
+ "The next step: the passkey's signature of the key authorization.",
596
640
  }),
597
641
  })
598
642
  .meta({
599
- description:
600
- 'An approval that continues when the passkey signs the key authorization.',
643
+ description: 'An approval that waits for the passkey to sign the key.',
601
644
  id: 'PasskeyStep',
602
645
  })
603
646
 
@@ -609,24 +652,24 @@ export namespace schema {
609
652
  .int()
610
653
  .meta({
611
654
  description:
612
- "The key's expiry in Unix seconds. The repeated request sends it as `key_authorization.expiry`, so the digest stays the same.",
655
+ "The key's expiry, in Unix seconds. Send it as `key_authorization.expiry` in the repeat, so the digest stays the same.",
613
656
  examples: [1791869364],
614
657
  }),
615
658
  step: step.shape.step
616
659
  .extend({
617
660
  type: z.literal('passkey').meta({
618
661
  description:
619
- 'Sign the options with the passkey on the page, then repeat `POST /v1/authorize` with `passkey: { assertion }`.',
662
+ 'Sign the challenge with the passkey, then repeat `POST /v1/authorize` with `passkey: { assertion }`.',
620
663
  }),
621
664
  })
622
665
  .meta({
623
666
  description:
624
- "The next step: the passkey's signature over the key authorization's digest.",
667
+ "The next step: the passkey's signature of the key authorization.",
625
668
  }),
626
669
  })
627
670
  .meta({
628
671
  description:
629
- "A headless authorization that continues when the passkey that owns the account signs the key authorization on the app's page.",
672
+ "A headless authorization that waits for the account's passkey to sign the key.",
630
673
  id: 'HeadlessPasskeyStep',
631
674
  })
632
675
 
@@ -1167,7 +1210,7 @@ export async function requestProof(
1167
1210
  db: Realm.Database,
1168
1211
  options: requestProof.Options,
1169
1212
  ) {
1170
- const { credential, rpId, userId } = options
1213
+ const { credential, origin, rpId, userId } = options
1171
1214
  const challenge = Hex.fromBytes(crypto.getRandomValues(new Uint8Array(32)))
1172
1215
  await db.insert(Schema.verification).values({
1173
1216
  expiresAt: new Date(Date.now() + lifetime * 1000),
@@ -1177,7 +1220,7 @@ export async function requestProof(
1177
1220
  prefix: proofPrefix,
1178
1221
  userId,
1179
1222
  }),
1180
- value: JSON.stringify(schema.proof.parse({ ...credential, rpId })),
1223
+ value: JSON.stringify(schema.proof.parse({ ...credential, origin, rpId })),
1181
1224
  })
1182
1225
  return challenge
1183
1226
  }
@@ -1191,6 +1234,11 @@ export declare namespace requestProof {
1191
1234
  /** The uncompressed P256 public key that the attestation holds, hex. */
1192
1235
  publicKey: Hex.Hex
1193
1236
  }
1237
+ /**
1238
+ * The origin of the app's page that the ceremony runs at. Absent for Tempo ID's own page. Only
1239
+ * an assertion that reports it, from a request that page sends, spends the challenge.
1240
+ */
1241
+ origin?: string | undefined
1194
1242
  /** The relying party of the `default` realm, which the new passkey belongs to. */
1195
1243
  rpId: string
1196
1244
  /** The signed-in user's ID. */
@@ -1215,13 +1263,23 @@ export async function requestRegistration(
1215
1263
  prefix: registrationPrefix,
1216
1264
  userId: options.userId,
1217
1265
  }),
1218
- value: JSON.stringify(schema.registration.parse({ rpId: options.rpId })),
1266
+ value: JSON.stringify(
1267
+ schema.registration.parse({
1268
+ origin: options.origin,
1269
+ rpId: options.rpId,
1270
+ }),
1271
+ ),
1219
1272
  })
1220
1273
  return challenge
1221
1274
  }
1222
1275
 
1223
1276
  export declare namespace requestRegistration {
1224
1277
  type Options = {
1278
+ /**
1279
+ * The origin of the app's page that the ceremony runs at. Absent for Tempo ID's own page. Only
1280
+ * a credential that reports it, from a request that page sends, spends the challenge.
1281
+ */
1282
+ origin?: string | undefined
1225
1283
  /** The relying party of the `default` realm, which the new passkey must belong to. */
1226
1284
  rpId: string
1227
1285
  /** The signed-in user's ID. */
@@ -1487,7 +1545,14 @@ export async function takeProof(
1487
1545
  .returning({ value: Schema.verification.value })
1488
1546
  if (!row) return undefined
1489
1547
 
1490
- return { ...schema.proof.parse(JSON.parse(row.value)), challenge }
1548
+ const value = schema.proof.parse(JSON.parse(row.value))
1549
+ return {
1550
+ challenge,
1551
+ id: value.id,
1552
+ origin: value.origin,
1553
+ publicKey: value.publicKey,
1554
+ rpId: value.rpId,
1555
+ }
1491
1556
  }
1492
1557
 
1493
1558
  export declare namespace takeProof {
@@ -1540,7 +1605,7 @@ export async function takeRegistration(
1540
1605
  if (!row) return undefined
1541
1606
 
1542
1607
  const value = schema.registration.parse(JSON.parse(row.value))
1543
- return { challenge, rpId: value.rpId }
1608
+ return { challenge, origin: value.origin, rpId: value.rpId }
1544
1609
  }
1545
1610
 
1546
1611
  export declare namespace takeRegistration {
@@ -1896,7 +1961,7 @@ export declare namespace verifyRegistration {
1896
1961
  type Options = {
1897
1962
  /** The registration challenge that Tempo ID issued, as hex. */
1898
1963
  challenge: Hex.Hex
1899
- /** The origin the ceremony must report: Tempo ID's own. */
1964
+ /** The origin the ceremony must report: Tempo ID's own, or the app's page that the challenge names. */
1900
1965
  origin: string
1901
1966
  /** The relying party ID the challenge was issued for. */
1902
1967
  rpId: string
@@ -10,8 +10,10 @@ import { createViemAccount } from '@privy-io/node/viem'
10
10
  import { createMiddleware } from 'hono/factory'
11
11
  import { Base64 } from 'ox'
12
12
  import { Account } from 'viem/tempo'
13
+ import { z } from 'zod'
13
14
  import type { Env } from './Env.js'
14
15
  import * as ErrorResponse from './ErrorResponse.js'
16
+ import * as Log from './Log.js'
15
17
  import * as Timing from './Timing.js'
16
18
 
17
19
  /** No `PRIVY_JWT_PRIVATE_KEY` is set, so Tempo ID cannot sign as a Privy wallet. */
@@ -28,6 +30,20 @@ export class SigningKeyMissingError extends Error {
28
30
  /** How long a token for Privy lasts, in seconds: long enough for one signing request. */
29
31
  const tokenLifetime = 60
30
32
 
33
+ /** The name of the Cache API cache that holds users' Privy wallets, as {@link cachedWallets} reads them. */
34
+ const walletCache = 'privy-wallets'
35
+
36
+ /** Zod schemas for the wallets that {@link cachedWallets} stores. */
37
+ namespace schema {
38
+ export const wallets = z.array(
39
+ z.object({
40
+ address: z.templateLiteral(['0x', z.string().regex(/^[0-9a-fA-F]{40}$/)]),
41
+ createdAt: z.coerce.date(),
42
+ id: z.string().min(1),
43
+ }),
44
+ )
45
+ }
46
+
31
47
  /** A realm's Privy app, which holds the owner wallets of the realm's Privy-owned accounts. */
32
48
  export type App = {
33
49
  /** The Privy app ID. */
@@ -68,6 +84,101 @@ export type Wallet = {
68
84
  id: string
69
85
  }
70
86
 
87
+ /**
88
+ * Returns the user's Privy wallets in `app`, as {@link listWallets} does, from the data center's
89
+ * cache when a read in the last `maxAge` seconds stored them. A read from Privy that finds a wallet
90
+ * stores its result, so a later wallet that Privy creates elsewhere appears within `maxAge`. A user
91
+ * without a wallet is never stored, so a first wallet appears in the next read. The cache holds each wallet's address, ID, and creation time, never the
92
+ * app secret. Without the Cache API, as in the CLI, every call reads Privy.
93
+ */
94
+ export async function cachedWallets(
95
+ c: Context,
96
+ options: cachedWallets.Options,
97
+ ): Promise<readonly Wallet[]> {
98
+ if (typeof caches === 'undefined') return await listWallets(c, options)
99
+ const key = cacheKey(options)
100
+ const { warn = Log.warn } = options
101
+
102
+ // The cache only saves reads of Privy, so a failure of it falls back to Privy.
103
+ const cached = await (async () => {
104
+ try {
105
+ const cache = await Timing.measure(
106
+ c,
107
+ caches.open(walletCache),
108
+ 'cache.privyWallets.open',
109
+ )
110
+ const hit = await Timing.measure(
111
+ c,
112
+ cache.match(key),
113
+ 'cache.privyWallets.match',
114
+ )
115
+ if (!hit) return { cache, wallets: undefined }
116
+ const parsed = schema.wallets.safeParse(
117
+ await Timing.measure(c, hit.json(), 'cache.privyWallets.read'),
118
+ )
119
+ return { cache, wallets: parsed.success ? parsed.data : undefined }
120
+ } catch (error) {
121
+ warn({
122
+ error,
123
+ message:
124
+ 'A read of the cached Privy wallets failed, so Privy was read.',
125
+ })
126
+ return undefined
127
+ }
128
+ })()
129
+ if (cached?.wallets) return cached.wallets
130
+
131
+ const wallets = await listWallets(c, options)
132
+ // A user without a wallet stays uncached, so the read after Tempo ID creates their first wallet,
133
+ // the only wallet that Tempo ID creates, asks Privy again. A read in flight during the creation
134
+ // thus stores no stale empty list.
135
+ if (wallets.length === 0 || !cached) return wallets
136
+ const stored = Timing.measure(
137
+ c,
138
+ cached.cache
139
+ .put(
140
+ key,
141
+ new Response(JSON.stringify(wallets), {
142
+ headers: {
143
+ 'cache-control': `max-age=${options.maxAge}`,
144
+ 'content-type': 'application/json',
145
+ },
146
+ }),
147
+ )
148
+ .catch((error: unknown) => {
149
+ warn({
150
+ error,
151
+ message: 'A store of the Privy wallets in the cache failed.',
152
+ })
153
+ }),
154
+ 'cache.privyWallets.put',
155
+ )
156
+ // The store only serves later reads, so with `waitUntil` the wallets return before it settles.
157
+ if (options.waitUntil) options.waitUntil(stored)
158
+ else await stored
159
+ return wallets
160
+ }
161
+
162
+ export namespace cachedWallets {
163
+ export type Options = listWallets.Options & {
164
+ /** How old, in seconds, a cached read may be. */
165
+ maxAge: number
166
+ /**
167
+ * Keeps the store in the cache alive after the response, such as `c.executionCtx.waitUntil`,
168
+ * so the wallets return before it settles. Absent, the call waits for the store.
169
+ */
170
+ waitUntil?: ((promise: Promise<unknown>) => void) | undefined
171
+ /**
172
+ * Logs a failure of the cache with the request's details, such as `Log.warn` with the request's
173
+ * context. Defaults to `Log.warn` without them.
174
+ */
175
+ warn?: ((options: Log.Options) => void) | undefined
176
+ }
177
+
178
+ /** The refusals `cachedWallets` throws, which `ErrorResponse.handle` responds with: Privy's failures. */
179
+ export const errors = listWallets.errors
180
+ }
181
+
71
182
  /**
72
183
  * Records that the request created the user with `userId`, so the request reads no Privy user for
73
184
  * them from Privy. Privy keys each user by the user's ID, and no Privy app holds a new ID before
@@ -463,6 +574,23 @@ function inProgress(error: unknown) {
463
574
  )
464
575
  }
465
576
 
577
+ /**
578
+ * Returns the Cache API key of the wallets of the user with `userId` in `app`. The key names the app
579
+ * by its ID alone, never its secret, on a host that serves nothing.
580
+ */
581
+ function cacheKey(options: cacheKey.Options) {
582
+ return `https://privy-wallets.tempo-id.invalid/${encodeURIComponent(options.app.appId)}/${encodeURIComponent(options.userId)}`
583
+ }
584
+
585
+ declare namespace cacheKey {
586
+ type Options = {
587
+ /** The Privy app that holds the user. */
588
+ app: App
589
+ /** The user's ID. */
590
+ userId: string
591
+ }
592
+ }
593
+
466
594
  /**
467
595
  * Returns the key of the user with `userId` in `app`, in the request's {@link Users}. Without `app`,
468
596
  * the key marks a user that the request created.