@dejadex/web 0.0.0-stage → 0.1.0

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 (113) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +214 -2
  3. package/dist/errors.d.ts +8 -0
  4. package/dist/errors.d.ts.map +1 -0
  5. package/dist/errors.js +9 -0
  6. package/dist/errors.js.map +1 -0
  7. package/dist/geometry/homography.d.ts +75 -0
  8. package/dist/geometry/homography.d.ts.map +1 -0
  9. package/dist/geometry/homography.js +146 -0
  10. package/dist/geometry/homography.js.map +1 -0
  11. package/dist/identify/assets.d.ts +39 -0
  12. package/dist/identify/assets.d.ts.map +1 -0
  13. package/dist/identify/assets.js +167 -0
  14. package/dist/identify/assets.js.map +1 -0
  15. package/dist/identify/decode.d.ts +6 -0
  16. package/dist/identify/decode.d.ts.map +1 -0
  17. package/dist/identify/decode.js +53 -0
  18. package/dist/identify/decode.js.map +1 -0
  19. package/dist/identify/engine.d.ts +32 -0
  20. package/dist/identify/engine.d.ts.map +1 -0
  21. package/dist/identify/engine.js +127 -0
  22. package/dist/identify/engine.js.map +1 -0
  23. package/dist/identify/identifier.d.ts +81 -0
  24. package/dist/identify/identifier.d.ts.map +1 -0
  25. package/dist/identify/identifier.js +327 -0
  26. package/dist/identify/identifier.js.map +1 -0
  27. package/dist/identify/manifest.d.ts +60 -0
  28. package/dist/identify/manifest.d.ts.map +1 -0
  29. package/dist/identify/manifest.js +211 -0
  30. package/dist/identify/manifest.js.map +1 -0
  31. package/dist/identify/preprocess.d.ts +52 -0
  32. package/dist/identify/preprocess.d.ts.map +1 -0
  33. package/dist/identify/preprocess.js +195 -0
  34. package/dist/identify/preprocess.js.map +1 -0
  35. package/dist/identify/router.d.ts +60 -0
  36. package/dist/identify/router.d.ts.map +1 -0
  37. package/dist/identify/router.js +83 -0
  38. package/dist/identify/router.js.map +1 -0
  39. package/dist/identify/runtime.d.ts +69 -0
  40. package/dist/identify/runtime.d.ts.map +1 -0
  41. package/dist/identify/runtime.js +140 -0
  42. package/dist/identify/runtime.js.map +1 -0
  43. package/dist/identify/search.d.ts +35 -0
  44. package/dist/identify/search.d.ts.map +1 -0
  45. package/dist/identify/search.js +99 -0
  46. package/dist/identify/search.js.map +1 -0
  47. package/dist/identify/server.d.ts +60 -0
  48. package/dist/identify/server.d.ts.map +1 -0
  49. package/dist/identify/server.js +149 -0
  50. package/dist/identify/server.js.map +1 -0
  51. package/dist/identify/shard.d.ts +31 -0
  52. package/dist/identify/shard.d.ts.map +1 -0
  53. package/dist/identify/shard.js +146 -0
  54. package/dist/identify/shard.js.map +1 -0
  55. package/dist/identify/types.d.ts +107 -0
  56. package/dist/identify/types.d.ts.map +1 -0
  57. package/dist/identify/types.js +7 -0
  58. package/dist/identify/types.js.map +1 -0
  59. package/dist/identify/worker.d.ts +2 -0
  60. package/dist/identify/worker.d.ts.map +1 -0
  61. package/dist/identify/worker.js +13 -0
  62. package/dist/identify/worker.js.map +1 -0
  63. package/dist/identify/workerProtocol.d.ts +54 -0
  64. package/dist/identify/workerProtocol.d.ts.map +1 -0
  65. package/dist/identify/workerProtocol.js +113 -0
  66. package/dist/identify/workerProtocol.js.map +1 -0
  67. package/dist/index.d.ts +20 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +16 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/lease/verify.d.ts +69 -0
  72. package/dist/lease/verify.d.ts.map +1 -0
  73. package/dist/lease/verify.js +235 -0
  74. package/dist/lease/verify.js.map +1 -0
  75. package/dist/scanner.d.ts +10 -0
  76. package/dist/scanner.d.ts.map +1 -0
  77. package/dist/scanner.js +58 -0
  78. package/dist/scanner.js.map +1 -0
  79. package/dist/types.d.ts +180 -0
  80. package/dist/types.d.ts.map +1 -0
  81. package/dist/types.js +2 -0
  82. package/dist/types.js.map +1 -0
  83. package/dist/usage/meter.d.ts +83 -0
  84. package/dist/usage/meter.d.ts.map +1 -0
  85. package/dist/usage/meter.js +111 -0
  86. package/dist/usage/meter.js.map +1 -0
  87. package/dist/usage/store.d.ts +52 -0
  88. package/dist/usage/store.d.ts.map +1 -0
  89. package/dist/usage/store.js +198 -0
  90. package/dist/usage/store.js.map +1 -0
  91. package/package.json +64 -4
  92. package/src/errors.ts +24 -0
  93. package/src/geometry/homography.ts +178 -0
  94. package/src/identify/assets.ts +189 -0
  95. package/src/identify/decode.ts +54 -0
  96. package/src/identify/engine.ts +167 -0
  97. package/src/identify/identifier.ts +419 -0
  98. package/src/identify/manifest.ts +240 -0
  99. package/src/identify/preprocess.ts +227 -0
  100. package/src/identify/router.ts +115 -0
  101. package/src/identify/runtime.ts +197 -0
  102. package/src/identify/search.ts +115 -0
  103. package/src/identify/server.ts +167 -0
  104. package/src/identify/shard.ts +154 -0
  105. package/src/identify/types.ts +122 -0
  106. package/src/identify/worker.ts +13 -0
  107. package/src/identify/workerProtocol.ts +136 -0
  108. package/src/index.ts +71 -0
  109. package/src/lease/verify.ts +252 -0
  110. package/src/scanner.ts +56 -0
  111. package/src/types.ts +196 -0
  112. package/src/usage/meter.ts +165 -0
  113. package/src/usage/store.ts +245 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Lease verification on the device: an Ed25519 compact JWS (RFC 7515,
3
+ * `alg: EdDSA` per RFC 8037), `typ: dejadex-lease+jwt`, checked against the
4
+ * public keys the host embeds. Mirrors `service/src/leases/jws.ts` check for
5
+ * check, in the same order:
6
+ *
7
+ * structure → header → signature (over the raw bytes) → claims shape →
8
+ * time → origin → domains
9
+ *
10
+ * Nothing in an unsigned payload is looked at before the signature holds, and
11
+ * the algorithm comes from this code, never from the token.
12
+ *
13
+ * Signature: WebCrypto Ed25519 where the browser has it (Chrome 137+, Safari
14
+ * 17+, Firefox 129+, Node 22); elsewhere `@noble/ed25519` (MIT, audited,
15
+ * zero dependencies), loaded only then.
16
+ *
17
+ * The browser is untrusted: this check keeps an honest host from running on a
18
+ * lease it should not, and bounds what a dishonest one gets. The service
19
+ * checks every lease again.
20
+ */
21
+ import { ScannerError } from '../errors.js'
22
+ import type { Lease, LeaseClaims } from '../types.js'
23
+
24
+ export const LEASE_ALG = 'EdDSA'
25
+ export const LEASE_TYP = 'dejadex-lease+jwt'
26
+ export const MAX_LEASE_TTL_SECONDS = 86_400
27
+ export const DEFAULT_CLOCK_SKEW_SECONDS = 60
28
+
29
+ /** An Ed25519 public key as a JWK (`kty: OKP`, `crv: Ed25519`), or just its `x`. */
30
+ export interface LeasePublicKey {
31
+ readonly kty?: 'OKP'
32
+ readonly crv?: 'Ed25519'
33
+ /** The raw 32-byte key, base64url. */
34
+ readonly x: string
35
+ /** When set, only leases whose header names this `kid` are checked against it. */
36
+ readonly kid?: string
37
+ /** Carried by a JWKS entry; `EdDSA` / `sig` when present. */
38
+ readonly alg?: string
39
+ readonly use?: string
40
+ }
41
+
42
+ export type Ed25519Verify = (publicKey: Uint8Array<ArrayBuffer>, message: Uint8Array<ArrayBuffer>, signature: Uint8Array<ArrayBuffer>) => Promise<boolean>
43
+
44
+ export interface VerifyLeaseOptions {
45
+ readonly publicKeys: readonly LeasePublicKey[]
46
+ /** The page origin, matched exactly against `origins`; null skips the check (no page). */
47
+ readonly origin: string | null
48
+ /** Domains the caller wants; each must be granted. */
49
+ readonly domains: readonly string[]
50
+ /** Unix seconds. Default: the device clock. */
51
+ readonly now?: number
52
+ readonly clockSkewSeconds?: number
53
+ /** Test seam; default WebCrypto, then `@noble/ed25519`. */
54
+ readonly verify?: Ed25519Verify
55
+ }
56
+
57
+ /** The service's reason, first in every message, so support can match the two sides. */
58
+ export type LeaseFailure =
59
+ | 'LEASE_MALFORMED'
60
+ | 'LEASE_BAD_SIGNATURE'
61
+ | 'LEASE_EXPIRED'
62
+ | 'LEASE_NOT_YET_VALID'
63
+ | 'LEASE_ORIGIN_NOT_ALLOWED'
64
+ | 'LEASE_DOMAIN_NOT_ALLOWED'
65
+
66
+ const SCANNER_CODE = {
67
+ LEASE_MALFORMED: 'LEASE_INVALID',
68
+ LEASE_BAD_SIGNATURE: 'LEASE_INVALID',
69
+ LEASE_NOT_YET_VALID: 'LEASE_INVALID',
70
+ LEASE_EXPIRED: 'LEASE_EXPIRED',
71
+ LEASE_ORIGIN_NOT_ALLOWED: 'ORIGIN_NOT_ALLOWED',
72
+ LEASE_DOMAIN_NOT_ALLOWED: 'DOMAIN_NOT_ALLOWED',
73
+ } as const
74
+
75
+ export class LeaseVerificationError extends ScannerError {
76
+ constructor(
77
+ readonly reason: LeaseFailure,
78
+ message: string,
79
+ ) {
80
+ super(SCANNER_CODE[reason], `${reason}: ${message}`)
81
+ }
82
+ }
83
+
84
+ const fail = (reason: LeaseFailure, message: string): never => {
85
+ throw new LeaseVerificationError(reason, message)
86
+ }
87
+ const malformed = (why: string): never => fail('LEASE_MALFORMED', `malformed lease: ${why}`)
88
+
89
+ const B64URL = /^[A-Za-z0-9_-]*$/
90
+
91
+ /** Strict unpadded base64url: one decoding and one encoding per string, or null. */
92
+ export function base64urlDecodeStrict(s: string): Uint8Array<ArrayBuffer> | null {
93
+ if (!B64URL.test(s) || s.length % 4 === 1) return null
94
+ const b64 = s.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (s.length % 4)) % 4)
95
+ let bin: string
96
+ try {
97
+ bin = atob(b64)
98
+ } catch {
99
+ return null
100
+ }
101
+ const out = new Uint8Array(bin.length)
102
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
103
+ // Non-zero trailing bits decode like the canonical form; re-encoding exposes them.
104
+ if (base64urlEncode(out) !== s) return null
105
+ return out
106
+ }
107
+
108
+ export function base64urlEncode(bytes: Uint8Array): string {
109
+ let bin = ''
110
+ for (const b of bytes) bin += String.fromCharCode(b)
111
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
112
+ }
113
+
114
+ function jsonObject(bytes: Uint8Array): Record<string, unknown> | null {
115
+ let value: unknown
116
+ try {
117
+ value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
118
+ } catch {
119
+ return null
120
+ }
121
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null
122
+ }
123
+
124
+ interface Header {
125
+ readonly kid?: string
126
+ }
127
+
128
+ function decodeHeader(segment: string): Header {
129
+ const bytes = base64urlDecodeStrict(segment)
130
+ if (bytes === null || bytes.length === 0) return malformed('header is not base64url')
131
+ const header = jsonObject(bytes)
132
+ if (header === null) return malformed('header is not a JSON object')
133
+ if (header.alg !== LEASE_ALG) return malformed(`unsupported alg ${JSON.stringify(header.alg)}`)
134
+ if (header.typ !== LEASE_TYP) return malformed(`unexpected typ ${JSON.stringify(header.typ)}`)
135
+ if ('crit' in header) return malformed('crit header not supported')
136
+ for (const key of Object.keys(header)) {
137
+ if (key !== 'alg' && key !== 'typ' && key !== 'kid') return malformed(`unexpected header parameter ${key}`)
138
+ }
139
+ if (header.kid !== undefined && (typeof header.kid !== 'string' || header.kid.length === 0)) return malformed('kid must be a non-empty string')
140
+ return header.kid === undefined ? {} : { kid: header.kid as string }
141
+ }
142
+
143
+ const nonEmpty = (v: unknown): v is string => typeof v === 'string' && v.length > 0
144
+ const stringArray = (v: unknown): v is string[] => Array.isArray(v) && v.length > 0 && v.every(nonEmpty)
145
+ const unixSeconds = (v: unknown): v is number => Number.isSafeInteger(v) && (v as number) >= 0
146
+
147
+ function toClaims(raw: Record<string, unknown>): LeaseClaims {
148
+ const { iss, sub, uid, jti, origins, domains, modelVersion, indexVersion, allowance, iat, exp } = raw
149
+ if (!nonEmpty(iss)) return malformed('iss')
150
+ if (!nonEmpty(sub)) return malformed('sub')
151
+ if (!nonEmpty(uid)) return malformed('uid')
152
+ if (!nonEmpty(jti)) return malformed('jti')
153
+ if (!stringArray(origins)) return malformed('origins')
154
+ if (!stringArray(domains)) return malformed('domains')
155
+ if (!nonEmpty(modelVersion)) return malformed('modelVersion')
156
+ if (!nonEmpty(indexVersion)) return malformed('indexVersion')
157
+ if (!Number.isSafeInteger(allowance) || (allowance as number) <= 0) return malformed('allowance')
158
+ if (!unixSeconds(iat)) return malformed('iat')
159
+ if (!unixSeconds(exp)) return malformed('exp')
160
+ if (exp <= iat) return malformed('exp must be after iat')
161
+ if (exp - iat > MAX_LEASE_TTL_SECONDS) return malformed('lifetime exceeds 24 h')
162
+ return { iss, sub, uid, jti, origins: [...origins], domains: [...domains], modelVersion, indexVersion, allowance: allowance as number, iat, exp }
163
+ }
164
+
165
+ /** The encoding of the Ed25519 base point: a key every implementation must accept. */
166
+ const BASE_POINT = new Uint8Array([0x58, ...new Array<number>(31).fill(0x66)])
167
+ let webCryptoEd25519: Promise<boolean> | null = null
168
+
169
+ /** Whether this runtime's WebCrypto does Ed25519, probed once with a known-good key. */
170
+ export function hasWebCryptoEd25519(): Promise<boolean> {
171
+ webCryptoEd25519 ??= (async () => {
172
+ const subtle = globalThis.crypto?.subtle
173
+ if (!subtle) return false
174
+ try {
175
+ await subtle.importKey('raw', BASE_POINT, { name: 'Ed25519' }, false, ['verify'])
176
+ return true
177
+ } catch {
178
+ return false
179
+ }
180
+ })()
181
+ return webCryptoEd25519
182
+ }
183
+
184
+ /** WebCrypto Ed25519 when the runtime has it, else `@noble/ed25519`. Any error is "does not verify". */
185
+ export const defaultEd25519Verify: Ed25519Verify = async (publicKey, message, signature) => {
186
+ try {
187
+ if (await hasWebCryptoEd25519()) {
188
+ const subtle = globalThis.crypto.subtle
189
+ const key = await subtle.importKey('raw', publicKey, { name: 'Ed25519' }, false, ['verify'])
190
+ return await subtle.verify({ name: 'Ed25519' }, key, signature, message)
191
+ }
192
+ return await nobleEd25519Verify(publicKey, message, signature)
193
+ } catch {
194
+ return false
195
+ }
196
+ }
197
+
198
+ /** The fallback on its own, for runtimes without WebCrypto Ed25519 (and its tests). */
199
+ export const nobleEd25519Verify: Ed25519Verify = async (publicKey, message, signature) => {
200
+ const noble = await import('@noble/ed25519')
201
+ try {
202
+ return await noble.verifyAsync(signature, message, publicKey)
203
+ } catch {
204
+ return false
205
+ }
206
+ }
207
+
208
+ /** Verifies `token` and returns the lease, or throws a `LeaseVerificationError`. */
209
+ export async function verifyLease(token: string, options: VerifyLeaseOptions): Promise<Lease> {
210
+ if (typeof token !== 'string') return malformed('token is not a string')
211
+ const parts = token.split('.')
212
+ if (parts.length !== 3) return malformed('expected three dot-separated segments')
213
+ const [headerB64 = '', payloadB64 = '', signatureB64 = ''] = parts
214
+ const header = decodeHeader(headerB64)
215
+ const payload = base64urlDecodeStrict(payloadB64)
216
+ if (payload === null || payload.length === 0) return malformed('payload is not base64url')
217
+ const signature = base64urlDecodeStrict(signatureB64)
218
+ if (signature === null) return malformed('signature is not base64url')
219
+ if (signature.length !== 64) return fail('LEASE_BAD_SIGNATURE', 'signature has the wrong length')
220
+
221
+ const keys = options.publicKeys.filter((k) => header.kid === undefined || k.kid === undefined || k.kid === header.kid)
222
+ if (keys.length === 0) return fail('LEASE_BAD_SIGNATURE', header.kid ? `no public key with kid ${header.kid}` : 'no public key')
223
+ const message = new TextEncoder().encode(`${headerB64}.${payloadB64}`)
224
+ const verify = options.verify ?? defaultEd25519Verify
225
+ let valid = false
226
+ for (const key of keys) {
227
+ if ((key.kty !== undefined && key.kty !== 'OKP') || (key.crv !== undefined && key.crv !== 'Ed25519')) continue
228
+ if ((key.alg !== undefined && key.alg !== LEASE_ALG) || (key.use !== undefined && key.use !== 'sig')) continue
229
+ const raw = base64urlDecodeStrict(key.x)
230
+ if (raw === null || raw.length !== 32) continue
231
+ if (await verify(raw, message, signature)) {
232
+ valid = true
233
+ break
234
+ }
235
+ }
236
+ if (!valid) return fail('LEASE_BAD_SIGNATURE', 'signature does not verify')
237
+
238
+ const raw = jsonObject(payload)
239
+ if (raw === null) return malformed('payload is not a JSON object')
240
+ const claims = toClaims(raw)
241
+
242
+ const now = options.now ?? Math.floor(Date.now() / 1000)
243
+ const skew = options.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS
244
+ if (now >= claims.exp) return fail('LEASE_EXPIRED', `lease expired at ${claims.exp}`)
245
+ if (claims.iat > now + skew) return fail('LEASE_NOT_YET_VALID', `lease issued at ${claims.iat}, in the future`)
246
+ if (options.origin !== null && !claims.origins.includes(options.origin)) {
247
+ return fail('LEASE_ORIGIN_NOT_ALLOWED', `origin ${options.origin} is not allowed by this lease`)
248
+ }
249
+ const missing = options.domains.filter((d) => !claims.domains.includes(d))
250
+ if (missing.length > 0) return fail('LEASE_DOMAIN_NOT_ALLOWED', `domains not granted: ${missing.join(', ')}`)
251
+ return { token, claims }
252
+ }
package/src/scanner.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { ScannerError } from './errors.js'
2
+ import type { DeviceTier, Scanner, ScannerOptions, ScannerState, TrackResult } from './types.js'
3
+
4
+ const DOMAIN_CODE = /^[a-z][a-z0-9]*_[a-z]{2}$/
5
+
6
+ /**
7
+ * Creates a scanner. Options are checked now, so a misconfigured host fails
8
+ * at the call site rather than at the first frame.
9
+ *
10
+ * STUB: the pipeline (openspec/changes/web-sdk-pipeline) is not built yet.
11
+ * `start` and `identify` reject with `NOT_IMPLEMENTED`; the shape is final.
12
+ */
13
+ export function createScanner(options: ScannerOptions): Scanner {
14
+ validate(options)
15
+
16
+ let state: ScannerState = 'idle'
17
+ const notYet = (what: string) =>
18
+ Promise.reject(new ScannerError('NOT_IMPLEMENTED', `${what} is not implemented yet`))
19
+
20
+ return {
21
+ get state() {
22
+ return state
23
+ },
24
+ get tier(): DeviceTier | null {
25
+ return null
26
+ },
27
+ start() {
28
+ if (state === 'destroyed') return Promise.reject(new ScannerError('INVALID_OPTIONS', 'scanner was destroyed'))
29
+ return notYet('live scanning')
30
+ },
31
+ stop() {
32
+ if (state === 'running') state = 'stopped'
33
+ },
34
+ identify(): Promise<readonly TrackResult[]> {
35
+ return notYet('snapshot identification')
36
+ },
37
+ async destroy() {
38
+ state = 'destroyed'
39
+ },
40
+ }
41
+ }
42
+
43
+ function validate(options: ScannerOptions): void {
44
+ const fail = (message: string): never => {
45
+ throw new ScannerError('INVALID_OPTIONS', message)
46
+ }
47
+ if (typeof options?.getLease !== 'function') fail('getLease must be a function')
48
+ if (typeof options.reportUsage !== 'function') fail('reportUsage must be a function')
49
+ if (typeof options.onTracks !== 'function') fail('onTracks must be a function')
50
+ if (!Array.isArray(options.domains) || options.domains.length === 0) fail('domains must list at least one domain')
51
+ for (const d of options.domains) {
52
+ if (typeof d !== 'string' || !DOMAIN_CODE.test(d)) fail(`not a domain code: ${String(d)}`)
53
+ }
54
+ const tiers: readonly unknown[] = ['auto', 'live_full', 'live_reduced', 'snapshot']
55
+ if (options.tier !== undefined && !tiers.includes(options.tier)) fail(`unknown tier: ${String(options.tier)}`)
56
+ }
package/src/types.ts ADDED
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The SDK's public types. These are the contract the first client tenant (and
3
+ * every later one) builds against, so they are settled before the pipeline
4
+ * behind them exists: see openspec/changes/web-sdk-pipeline and
5
+ * openspec/changes/lease-licensing for the behaviour each field promises.
6
+ */
7
+ import type { Homography, Quad } from './geometry/homography.js'
8
+
9
+ /**
10
+ * A catalogue domain: one franchise in one language, and one index shard.
11
+ * Codes are neutral by rule (`tcg01_en`, `tcg01_ja`, `tcg02_en`, …); a
12
+ * franchise name never appears in one.
13
+ */
14
+ export type DomainCode = string
15
+
16
+ /** A print's platform-owned id. Opaque to clients; map through `externalIds`. */
17
+ export type PrintId = string
18
+
19
+ /**
20
+ * Ids of the same print in third-party catalogues, keyed by source
21
+ * (e.g. `{ tcgdex: "<set>-<number>" }`). A client maps its own ids through
22
+ * the source it already uses.
23
+ */
24
+ export type ExternalIds = Readonly<Record<string, string>>
25
+
26
+ /** A finish of a print, e.g. `normal`, `holo`, `reverse_holo`, `first_edition`. Domain-defined. */
27
+ export type VariantCode = string
28
+
29
+ /** What the device can sustain, chosen at start-up (`auto`) or forced by the host. */
30
+ export type DeviceTier = 'live_full' | 'live_reduced' | 'snapshot'
31
+
32
+ /** The claims of a lease, as the service signs them. Times are Unix seconds. */
33
+ export interface LeaseClaims {
34
+ /** The issuing service. */
35
+ readonly iss: string
36
+ /** The tenant the lease bills to. */
37
+ readonly sub: string
38
+ /** The tenant's end user, hashed by the service; never the client's raw id. */
39
+ readonly uid: string
40
+ /** This lease's id; usage reports and renewals name it. */
41
+ readonly jti: string
42
+ /** Page origins the lease runs on, exact `scheme://host[:port]` matches. */
43
+ readonly origins: readonly string[]
44
+ /** Index shards the lease unlocks. */
45
+ readonly domains: readonly DomainCode[]
46
+ /** The embedding model version; locked to `indexVersion`. */
47
+ readonly modelVersion: string
48
+ readonly indexVersion: string
49
+ /** Identifications this lease permits before it must be renewed. */
50
+ readonly allowance: number
51
+ readonly iat: number
52
+ readonly exp: number
53
+ }
54
+
55
+ /** A verified lease: the compact JWS the client's backend handed over, and its claims. */
56
+ export interface Lease {
57
+ readonly token: string
58
+ readonly claims: LeaseClaims
59
+ }
60
+
61
+ /** What the SDK asks the host for when it needs a lease. */
62
+ export interface LeaseRequest {
63
+ /** The lease being renewed, or null for the first one on this device. */
64
+ readonly previousLeaseId: string | null
65
+ /**
66
+ * The last usage sequence number already accepted by `reportUsage` for the
67
+ * previous lease. The service refuses a renewal whose previous lease has
68
+ * unreported usage.
69
+ */
70
+ readonly usageReportedThrough: number | null
71
+ }
72
+
73
+ /** What the host's backend returns from the service's `POST /v1/leases`, passed through as is. */
74
+ export interface LeaseGrant {
75
+ /** The signed lease, compact JWS. */
76
+ readonly token: string
77
+ /** Signed URL of the manifest that lists model and index files for this lease. */
78
+ readonly manifestUrl: string
79
+ }
80
+
81
+ /** One of the top-K guesses for a track. */
82
+ export interface Candidate {
83
+ readonly printId: PrintId
84
+ readonly externalIds: ExternalIds
85
+ /** Cosine similarity to the reference embedding, in [-1, 1]. */
86
+ readonly score: number
87
+ }
88
+
89
+ /**
90
+ * One physical card the SDK is following, as of the latest processed frame.
91
+ *
92
+ * `printId` is null until the track is confirmed, and stays null when the
93
+ * crop matched nothing above the threshold (`status: 'unknown'`): the
94
+ * catalogue is closed, so a miss is reported as a miss with its best guesses,
95
+ * never as a near neighbour.
96
+ */
97
+ export interface TrackResult {
98
+ /** Stable for as long as the card stays tracked. */
99
+ readonly trackId: number
100
+ readonly status: 'pending' | 'confirmed' | 'unknown'
101
+ /** Corners in the source frame's pixel space (y down): TL, TR, BR, BL of the card. The host maps them to its own layout (e.g. object-fit: cover) before drawing. */
102
+ readonly quad: Quad
103
+ /** Unit card rectangle → `quad`; `toCssMatrix3d` turns it into a transform. */
104
+ readonly homography: Homography
105
+ readonly domain: DomainCode | null
106
+ readonly printId: PrintId | null
107
+ readonly externalIds: ExternalIds
108
+ /** Null when the variant is unresolved: the host shows a picker, never a guess. */
109
+ readonly variant: VariantCode | null
110
+ /** In [0, 1]; the SDK's own calibrated belief in `printId`. */
111
+ readonly confidence: number
112
+ /** The top-K candidates (K = 5), best first, including the chosen one. */
113
+ readonly alternates: readonly Candidate[]
114
+ }
115
+
116
+ /**
117
+ * What one scan session counted under one lease since its previous report.
118
+ *
119
+ * The billing unit is a distinct card per scan session: a card is charged the
120
+ * first time it is identified in a session, by whichever side identified it,
121
+ * and never again in that session however often it is seen. Misses, "not a
122
+ * card" answers and retries are never charged. See openspec/changes/
123
+ * lease-licensing, usage-metering.
124
+ */
125
+ export interface UsageReport {
126
+ /** Unique per report, so a report sent twice is counted once. */
127
+ readonly reportId: string
128
+ /** The lease's `jti`. */
129
+ readonly leaseId: string
130
+ /** Per lease, from 1, up by exactly 1 per report; renewals name the last one accepted. */
131
+ readonly seq: number
132
+ /** The scan session: random, at least 128 bits, one per scanner session. */
133
+ readonly sessionId: string
134
+ /** Cards first identified in this session since its previous report, excluding any the service counted. */
135
+ readonly distinctCards: number
136
+ /** Those cards' print ids, so the service can charge each at most once per session. */
137
+ readonly cardIds?: readonly PrintId[]
138
+ /** Identify attempts behind this report, retries and "not a card" answers included. Informational, never charged. */
139
+ readonly calls?: number
140
+ /** Device clock, Unix milliseconds. Informational; the service orders by `seq`. */
141
+ readonly at?: number
142
+ }
143
+
144
+ /** A batch the SDK hands to `reportUsage`, flushed from its IndexedDB queue. */
145
+ export interface UsageBatch {
146
+ /** A random per-install id (not a fingerprint), for monthly active devices. */
147
+ readonly deviceId: string
148
+ /** May be empty: one batch per lease used is delivered even with nothing to report. */
149
+ readonly reports: readonly UsageReport[]
150
+ }
151
+
152
+ /** Where live frames come from. */
153
+ export type FrameSourceInput = MediaStream | HTMLVideoElement
154
+
155
+ export interface ScannerOptions {
156
+ /**
157
+ * Obtain a lease. The host calls its own backend, which calls the service's
158
+ * `POST /v1/leases` with the tenant's secret key; the secret never reaches
159
+ * the browser. Called at start and before the current lease runs out.
160
+ */
161
+ getLease(request: LeaseRequest): Promise<LeaseGrant>
162
+ /**
163
+ * Deliver queued usage. The host's backend forwards it to `POST /v1/usage`.
164
+ * Resolve only once it is accepted; a rejection leaves the batch queued.
165
+ */
166
+ reportUsage(batch: UsageBatch): Promise<void>
167
+ /** The domains to search; each must be granted by the lease. */
168
+ readonly domains: readonly DomainCode[]
169
+ /** Every processed frame's tracks, on the main thread. */
170
+ onTracks(tracks: readonly TrackResult[]): void
171
+ /** Errors the host should know about (lease, device, downloads). */
172
+ onError?(error: Error): void
173
+ /** Force a tier, or let the SDK pick from the device's capabilities. Default `auto`. */
174
+ readonly tier?: DeviceTier | 'auto'
175
+ /**
176
+ * The scan session usage is charged by (each distinct card once per session).
177
+ * Give one per visit to the host's scanner; default: a new random id per scanner.
178
+ */
179
+ readonly sessionId?: string
180
+ }
181
+
182
+ export type ScannerState = 'idle' | 'starting' | 'running' | 'stopped' | 'failed' | 'destroyed'
183
+
184
+ export interface Scanner {
185
+ readonly state: ScannerState
186
+ /** The tier in use, once started. */
187
+ readonly tier: DeviceTier | null
188
+ /** Live mode: follow and identify every card in `source`. */
189
+ start(source: FrameSourceInput): Promise<void>
190
+ /** Pause live mode; tracks are dropped, caches are kept. */
191
+ stop(): void
192
+ /** Snapshot mode: identify every card in one image. */
193
+ identify(image: ImageBitmap | Blob): Promise<readonly TrackResult[]>
194
+ /** Release the Worker, the sessions and the camera hooks. */
195
+ destroy(): Promise<void>
196
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * What an identification costs: one per distinct card per scan session
3
+ * (lease-licensing, usage-metering).
4
+ *
5
+ * A session is one visit to a host's scanner (the host's `session` id, or the
6
+ * identifier's own). Within it, the first confident `card` answer for a
7
+ * `cardId` is charged once; the same card again in the session is free, and
8
+ * so are `not_a_card` answers, answers that are not confident (the host asks
9
+ * the user instead of asserting), and `retry` calls.
10
+ *
11
+ * A server answer that carries an `identificationId` was charged by the
12
+ * service itself: it adds no card to the report, but still marks the card as
13
+ * seen in the session and still counts toward the lease's allowance.
14
+ *
15
+ * Every call is tallied in the `UsageStore` before its answer reaches the
16
+ * host. `drain` seals the open tallies into `UsageReport`s and hands them to
17
+ * the host in batches; a batch leaves the store only once the host's delivery
18
+ * resolves.
19
+ */
20
+ import type { LeaseClaims, LeaseRequest, UsageBatch } from '../types.js'
21
+ import type { IdentifyPurpose, IdentifyResult } from '../identify/types.js'
22
+ import { randomId, type UsageStore } from './store.js'
23
+
24
+ /** Reports per batch handed to the host. */
25
+ export const MAX_BATCH_REPORTS = 500
26
+ /** Sessions whose seen cards are remembered at once; the oldest is forgotten first. */
27
+ const MAX_SESSIONS = 32
28
+
29
+ export interface MeterOptions {
30
+ readonly store: UsageStore
31
+ readonly now?: () => number
32
+ readonly newId?: () => string
33
+ }
34
+
35
+ export interface MeterInput {
36
+ readonly lease: LeaseClaims
37
+ readonly session: string
38
+ readonly purpose: IdentifyPurpose | undefined
39
+ readonly results: readonly IdentifyResult[]
40
+ }
41
+
42
+ export interface DrainResult {
43
+ /** Reports delivered by this drain. */
44
+ readonly delivered: number
45
+ /** Reports and open tallies still queued. */
46
+ readonly pending: number
47
+ }
48
+
49
+ export interface RecordResult {
50
+ /** Cards this call charged on the device (first seen in the session, not counted by the service). */
51
+ readonly charged: readonly string[]
52
+ /** Cards the service had already charged (`identificationId`). */
53
+ readonly serverCharged: number
54
+ }
55
+
56
+ export class UsageMeter {
57
+ private readonly store: UsageStore
58
+ private readonly now: () => number
59
+ private readonly newId: () => string
60
+ /** session → card ids already charged (by either side) in it. */
61
+ private readonly seen = new Map<string, Set<string>>()
62
+ /** lease id → cards charged under it on this page (device + service). */
63
+ private readonly used = new Map<string, number>()
64
+ private chain: Promise<unknown> = Promise.resolve()
65
+
66
+ constructor(options: MeterOptions) {
67
+ this.store = options.store
68
+ this.now = options.now ?? (() => Date.now())
69
+ this.newId = options.newId ?? (() => (typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : randomId()))
70
+ }
71
+
72
+ /** Cards charged under `leaseId` on this page so far. */
73
+ usedUnder(leaseId: string): number {
74
+ return this.used.get(leaseId) ?? 0
75
+ }
76
+
77
+ /** Whether the lease's allowance is used up. */
78
+ exhausted(lease: LeaseClaims): boolean {
79
+ return this.usedUnder(lease.jti) >= lease.allowance
80
+ }
81
+
82
+ /** Tallies one call's answers; resolves once the tally is stored. */
83
+ record(input: MeterInput): Promise<RecordResult> {
84
+ return this.serial(async () => {
85
+ const charged: string[] = []
86
+ let serverCharged = 0
87
+ if (input.purpose !== 'retry') {
88
+ const seen = this.sessionSet(input.session)
89
+ for (const r of input.results) {
90
+ const top = r.candidates[0]
91
+ if (r.kind !== 'card' || !r.confident || !top || seen.has(top.cardId)) continue
92
+ seen.add(top.cardId)
93
+ if (r.identificationId !== undefined) serverCharged++
94
+ else charged.push(top.cardId)
95
+ }
96
+ }
97
+ const leaseId = input.lease.jti
98
+ this.used.set(leaseId, this.usedUnder(leaseId) + serverCharged + charged.length)
99
+ await this.store.tally(leaseId, input.session, charged, 1, this.now())
100
+ return { charged, serverCharged }
101
+ })
102
+ }
103
+
104
+ /**
105
+ * Seals the open tallies and hands the queued reports to `deliver` in
106
+ * batches of at most 500, oldest first. Each batch is removed once `deliver`
107
+ * resolves; a rejection stops the drain, leaves that batch queued, and is
108
+ * rethrown.
109
+ */
110
+ async drain(deliver: (batch: UsageBatch) => Promise<void>): Promise<DrainResult> {
111
+ // Sealing goes through the record queue, so a tally being written is not split.
112
+ await this.serial(() => this.store.seal(this.newId))
113
+ const deviceId = await this.store.deviceId()
114
+ let delivered = 0
115
+ for (;;) {
116
+ const reports = await this.store.peek(MAX_BATCH_REPORTS)
117
+ if (reports.length === 0) break
118
+ await deliver({ deviceId, reports })
119
+ await this.store.remove(reports.map((r) => r.reportId))
120
+ await this.store.markReported(reports)
121
+ delivered += reports.length
122
+ }
123
+ return { delivered, pending: await this.store.size() }
124
+ }
125
+
126
+ pending(): Promise<number> {
127
+ return this.store.size()
128
+ }
129
+
130
+ /**
131
+ * Makes sure the lease gets at least one report, even if nothing is ever
132
+ * identified under it: the service renews a lease only once the previous
133
+ * one has reported (an empty report counts).
134
+ */
135
+ touch(leaseId: string, session: string): Promise<void> {
136
+ return this.serial(() => this.store.tally(leaseId, session, [], 0, this.now()))
137
+ }
138
+
139
+ /** What the host's lease request carries about the lease being renewed. */
140
+ async leaseRequest(previousLeaseId: string | null): Promise<LeaseRequest> {
141
+ return {
142
+ previousLeaseId,
143
+ usageReportedThrough: previousLeaseId === null ? null : await this.store.reportedThrough(previousLeaseId),
144
+ }
145
+ }
146
+
147
+ private sessionSet(session: string): Set<string> {
148
+ let set = this.seen.get(session)
149
+ if (!set) {
150
+ set = new Set()
151
+ this.seen.set(session, set)
152
+ if (this.seen.size > MAX_SESSIONS) {
153
+ const oldest = this.seen.keys().next().value
154
+ if (oldest !== undefined) this.seen.delete(oldest)
155
+ }
156
+ }
157
+ return set
158
+ }
159
+
160
+ private serial<T>(task: () => Promise<T>): Promise<T> {
161
+ const run = this.chain.then(task, task)
162
+ this.chain = run.catch(() => undefined)
163
+ return run
164
+ }
165
+ }