Viewing is open to everyone. Downloading and starring need a GitHub sign-in.
Sign in with GitHub
api.mjs
v0.1.0-build.85
Latest
Commit ede38f5
Sign in with GitHub to download. Each download is recorded with your GitHub username.
| 1 | // The CKey Web SDK object, built on an instantiated Emscripten module of the |
| 2 | // C core plus the bridge in sdk/web/src/ckey_web.cpp. Every encode, decode |
| 3 | // and render call goes to the core; this file only marshals data. |
| 4 | |
| 5 | import { |
| 6 | LEVELS, |
| 7 | LAYOUTS, |
| 8 | certifyOptions, |
| 9 | encodeOptions, |
| 10 | grayFrame, |
| 11 | payloadBytes, |
| 12 | pdfOptions, |
| 13 | rasterOptions, |
| 14 | rgbaFrame, |
| 15 | svgOptions, |
| 16 | telemetryMode, |
| 17 | } from './options.mjs'; |
| 18 | import { classifyPayload, readableText } from './payload.mjs'; |
| 19 | import { TelemetrySender, installBackgroundSend, monotonicNow, utcDay } from './telemetry.mjs'; |
| 20 | import { makeScanner } from './scanner.mjs'; |
| 21 | import { SafeActions } from './safe-actions.mjs'; |
| 22 | |
| 23 | const PROFILES = ['0', '0.1', '1', '2', '3']; // indexed by the core's profile code |
| 24 | const ADJUSTMENTS = ['ink', 'accents', 'effects', 'logo', 'background', 'materials']; |
| 25 | const STYLE_POLICY_NAMES = ['strict', 'adaptive', 'override']; |
| 26 | const utf8 = new TextEncoder(); |
| 27 | const utf8d = new TextDecoder(); |
| 28 | |
| 29 | /** |
| 30 | * @param M instantiated Emscripten module (exports _ckw_* and HEAPU8) |
| 31 | * @param options { fetch, telemetryEndpoint, telemetry, now, day, document, window } |
| 32 | */ |
| 33 | export function createCKey(M, options = {}) { |
| 34 | if (!M || typeof M._ckw_encode !== 'function') throw new Error('CKey core module is missing its exports'); |
| 35 | if (!M.HEAPU8) throw new Error('CKey core module does not export HEAPU8'); |
| 36 | const now = options.now || monotonicNow; |
| 37 | const heap = () => M.HEAPU8; // re-read after every call: memory may grow |
| 38 | |
| 39 | // ---- memory helpers ------------------------------------------------------ |
| 40 | const alloc = (n) => { |
| 41 | const p = M._ckw_malloc(n); |
| 42 | if (!p) throw new Error('CKey: out of memory'); |
| 43 | return p; |
| 44 | }; |
| 45 | const copyIn = (bytes) => { |
| 46 | const p = alloc(bytes.length); |
| 47 | heap().set(bytes, p); |
| 48 | return p; |
| 49 | }; |
| 50 | const cstring = (p) => { |
| 51 | const h = heap(); |
| 52 | let e = p; |
| 53 | while (h[e] !== 0) e++; |
| 54 | return utf8d.decode(h.subarray(p, e)); |
| 55 | }; |
| 56 | const withCString = (s, fn) => { |
| 57 | const b = utf8.encode(s); |
| 58 | const p = alloc(b.length + 1); |
| 59 | heap().set(b, p); |
| 60 | heap()[p + b.length] = 0; |
| 61 | try { |
| 62 | return fn(p); |
| 63 | } finally { |
| 64 | M._ckw_free(p); |
| 65 | } |
| 66 | }; |
| 67 | const lastError = (fallback) => { |
| 68 | const m = cstring(M._ckw_last_error()); |
| 69 | return m || fallback; |
| 70 | }; |
| 71 | /** Copies a malloc'd result of ckw_last_len() bytes out and frees it. */ |
| 72 | const takeOut = (p, what) => { |
| 73 | if (!p) throw new Error(`CKey: ${lastError(`${what} failed`)}`); |
| 74 | const n = M._ckw_last_len(); |
| 75 | const out = heap().slice(p, p + n); |
| 76 | M._ckw_free(p); |
| 77 | return out; |
| 78 | }; |
| 79 | const u32s = (p, n) => Array.from(new Uint32Array(heap().buffer, p, n)); |
| 80 | const f64s = (p, n) => Array.from(new Float64Array(heap().buffer, p, n)); |
| 81 | |
| 82 | // ---- Safe Actions (verification is the core's) ----------------------------- |
| 83 | const safeActionCall = (bytes, key, nowSeconds, maxLifetime) => { |
| 84 | if (typeof M._ckw_safe_action !== 'function') throw new Error('this CKey core has no Safe Actions'); |
| 85 | const p = copyIn(bytes); |
| 86 | const k = key ? copyIn(key) : 0; |
| 87 | try { |
| 88 | const out = M._ckw_safe_action(p, bytes.length, k, nowSeconds, maxLifetime); |
| 89 | if (!out) throw new Error(lastError('not a valid Safe Action')); |
| 90 | const fields = JSON.parse(cstring(out)); |
| 91 | M._ckw_free(out); |
| 92 | fields.scope = fields.scope === 1 ? 'cross-device' : 'same-device'; |
| 93 | fields.params = Uint8Array.from((fields.params.match(/../g) || []).map((b) => parseInt(b, 16))); |
| 94 | return fields; |
| 95 | } finally { |
| 96 | M._ckw_free(p); |
| 97 | if (k) M._ckw_free(k); |
| 98 | } |
| 99 | }; |
| 100 | const safeActionCore = { |
| 101 | parse: (bytes) => safeActionCall(bytes, null, 0, 0), |
| 102 | verify: (bytes, key, nowSeconds, maxLifetime) => safeActionCall(bytes, key, nowSeconds, maxLifetime), |
| 103 | }; |
| 104 | |
| 105 | // ---- symbols ------------------------------------------------------------- |
| 106 | const registry = |
| 107 | typeof FinalizationRegistry === 'function' ? new FinalizationRegistry((ptr) => M._ckw_symbol_free(ptr)) : null; |
| 108 | |
| 109 | class CKeySymbol { |
| 110 | #ptr; |
| 111 | constructor(ptr) { |
| 112 | this.#ptr = ptr; |
| 113 | const info = alloc(20); |
| 114 | try { |
| 115 | M._ckw_symbol_info(ptr, info); |
| 116 | const [version, level, layout, payloadLength] = u32s(info, 5); |
| 117 | Object.defineProperties(this, { |
| 118 | version: { value: version, enumerable: true }, |
| 119 | level: { value: LEVELS[level], enumerable: true }, |
| 120 | layout: { value: LAYOUTS[layout], enumerable: true }, |
| 121 | payloadLength: { value: payloadLength, enumerable: true }, |
| 122 | capacity: { value: M._ckw_scalable_capacity(version, level, layout), enumerable: true }, |
| 123 | modules: { value: M._ckw_scalable_modules_across(version), enumerable: true }, |
| 124 | }); |
| 125 | } finally { |
| 126 | M._ckw_free(info); |
| 127 | } |
| 128 | if (registry) registry.register(this, ptr, this); |
| 129 | } |
| 130 | /** @internal */ |
| 131 | static ptrOf(sym) { |
| 132 | if (!(sym instanceof CKeySymbol)) throw new TypeError('expected a symbol from this CKey instance (ckey.encode)'); |
| 133 | if (!sym.#ptr) throw new Error('CKey: this symbol has been freed'); |
| 134 | return sym.#ptr; |
| 135 | } |
| 136 | get freed() { |
| 137 | return !this.#ptr; |
| 138 | } |
| 139 | /** Releases the symbol's WebAssembly memory. Safe to call twice. */ |
| 140 | free() { |
| 141 | if (!this.#ptr) return; |
| 142 | if (registry) registry.unregister(this); |
| 143 | M._ckw_symbol_free(this.#ptr); |
| 144 | this.#ptr = 0; |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | // ---- styles -------------------------------------------------------------- |
| 149 | /** Runs fn(stylePtr) with a core style built from a styleSpec (0 = default). */ |
| 150 | const withStyle = (spec, fn) => { |
| 151 | if (!spec) return fn(0); |
| 152 | const ws = M._ckw_style_new(spec.preset); |
| 153 | if (!ws) throw new Error(`CKey: ${lastError('invalid style preset')}`); |
| 154 | try { |
| 155 | for (const [field, r, g, b] of spec.rgb) { |
| 156 | if (!M._ckw_style_set_rgb(ws, field, r, g, b)) throw new RangeError('CKey: invalid style colour'); |
| 157 | } |
| 158 | for (const [field, v] of spec.num) { |
| 159 | if (!M._ckw_style_set_num(ws, field, v)) { |
| 160 | throw new RangeError( |
| 161 | field === 10 ? 'CKey: this core build supports only the strict style policy' : 'CKey: invalid style value', |
| 162 | ); |
| 163 | } |
| 164 | } |
| 165 | if (spec.logoHref !== null) withCString(spec.logoHref, (p) => M._ckw_style_set_logo(ws, p)); |
| 166 | return fn(ws); |
| 167 | } finally { |
| 168 | M._ckw_style_free(ws); |
| 169 | } |
| 170 | }; |
| 171 | |
| 172 | // ---- rendering ----------------------------------------------------------- |
| 173 | const rasterRGBA = (sym, o) => { |
| 174 | const ptr = CKeySymbol.ptrOf(sym); |
| 175 | return withStyle(o.style, (ws) => { |
| 176 | let logoPtr = 0; |
| 177 | try { |
| 178 | if (o.logo) logoPtr = copyIn(o.logo.data.subarray(0, o.logo.width * o.logo.height * 4)); |
| 179 | const p = M._ckw_render_raster( |
| 180 | ptr, ws, o.size, o.policy, o.transparent ? 1 : 0, o.supersample, o.minFeaturePx, o.materials ? 1 : 0, 0, |
| 181 | logoPtr, o.logo ? o.logo.width : 0, o.logo ? o.logo.height : 0, |
| 182 | ); |
| 183 | return takeOut(p, 'render'); |
| 184 | } finally { |
| 185 | if (logoPtr) M._ckw_free(logoPtr); |
| 186 | } |
| 187 | }); |
| 188 | }; |
| 189 | |
| 190 | function renderPixels(sym, opts) { |
| 191 | const o = rasterOptions(opts); |
| 192 | const px = rasterRGBA(sym, o); |
| 193 | return { width: o.size, height: o.size, data: new Uint8ClampedArray(px.buffer, px.byteOffset, px.byteLength) }; |
| 194 | } |
| 195 | |
| 196 | function renderToCanvas(sym, canvas, opts) { |
| 197 | if (!canvas || typeof canvas.getContext !== 'function') { |
| 198 | throw new TypeError('renderToCanvas needs an HTMLCanvasElement or OffscreenCanvas'); |
| 199 | } |
| 200 | const img = renderPixels(sym, opts); |
| 201 | if (canvas.width !== img.width) canvas.width = img.width; |
| 202 | if (canvas.height !== img.height) canvas.height = img.height; |
| 203 | const ctx = canvas.getContext('2d'); |
| 204 | if (!ctx) throw new Error('CKey: could not get a 2d context from the canvas'); |
| 205 | let data; |
| 206 | if (typeof ImageData === 'function') { |
| 207 | data = new ImageData(img.data, img.width, img.height); |
| 208 | } else { |
| 209 | data = ctx.createImageData(img.width, img.height); |
| 210 | data.data.set(img.data); |
| 211 | } |
| 212 | ctx.putImageData(data, 0, 0); |
| 213 | return canvas; |
| 214 | } |
| 215 | |
| 216 | function renderPNG(sym, opts) { |
| 217 | const o = rasterOptions(opts); |
| 218 | const px = rasterRGBA(sym, o); |
| 219 | const p = copyIn(px); |
| 220 | try { |
| 221 | return takeOut(M._ckw_encode_png_rgba(p, o.size, o.size, o.dpi), 'PNG export'); |
| 222 | } finally { |
| 223 | M._ckw_free(p); |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | function renderSVG(sym, opts) { |
| 228 | const o = svgOptions(opts); |
| 229 | const ptr = CKeySymbol.ptrOf(sym); |
| 230 | return withStyle(o.style, (ws) => utf8d.decode(takeOut(M._ckw_render_svg(ptr, ws, o.scale), 'SVG export'))); |
| 231 | } |
| 232 | |
| 233 | function renderPDF(sym, opts) { |
| 234 | const o = pdfOptions(opts); |
| 235 | const ptr = CKeySymbol.ptrOf(sym); |
| 236 | return withStyle(o.style, (ws) => takeOut(M._ckw_render_pdf(ptr, ws, o.widthPt, o.transparent ? 1 : 0), 'PDF export')); |
| 237 | } |
| 238 | |
| 239 | /** Checks a style against a symbol: { ok, message } (the core's explanation). */ |
| 240 | function checkStyle(sym, opts) { |
| 241 | const o = rasterOptions({ preset: opts?.preset, style: opts?.style }); |
| 242 | const ptr = CKeySymbol.ptrOf(sym); |
| 243 | return withStyle(o.style, (ws) => { |
| 244 | const st = M._ckw_style_check(ptr, ws); |
| 245 | return { ok: st === 0, message: lastError(st === 0 ? 'style OK' : 'style refused') }; |
| 246 | }); |
| 247 | } |
| 248 | |
| 249 | /** Optical certification through the core's modelled scan (ABI 4 cores). */ |
| 250 | function certify(sym, opts) { |
| 251 | if (!M._ckw_has_certify || !M._ckw_has_certify()) throw new Error('CKey: this core build has no certification'); |
| 252 | const o = certifyOptions(opts); |
| 253 | const ptr = CKeySymbol.ptrOf(sym); |
| 254 | return withStyle(o.style, (ws) => { |
| 255 | const colours = o.colours.length ? copyIn(Uint8Array.from(o.colours.flat())) : 0; |
| 256 | const pixels = o.pixels ? copyIn(o.pixels.data.subarray(0, o.pixels.width * o.pixels.height * 4)) : 0; |
| 257 | const out = alloc(64); |
| 258 | try { |
| 259 | const st = M._ckw_certify( |
| 260 | ptr, ws, o.transparent ? 1 : 0, colours, o.colours.length, pixels, |
| 261 | o.pixels ? o.pixels.width : 0, o.pixels ? o.pixels.height : 0, |
| 262 | o.pxPerModule, o.blurPx, o.noise, o.glare, o.trials, out, 0, |
| 263 | ); |
| 264 | const message = lastError(''); |
| 265 | const [certified, policy, adjustments, trials, trialsPassed, minContrast, featureSeparation, logoLoad] = f64s(out, 8); |
| 266 | return { |
| 267 | ok: st === 0, |
| 268 | certified: certified === 1, |
| 269 | policy: STYLE_POLICY_NAMES[policy] ?? String(policy), |
| 270 | adjustments: ADJUSTMENTS.filter((_, i) => adjustments & (1 << i)), |
| 271 | trials, |
| 272 | trialsPassed, |
| 273 | minContrast, |
| 274 | featureSeparation, |
| 275 | logoLoad, |
| 276 | message, |
| 277 | }; |
| 278 | } finally { |
| 279 | M._ckw_free(out); |
| 280 | if (colours) M._ckw_free(colours); |
| 281 | if (pixels) M._ckw_free(pixels); |
| 282 | } |
| 283 | }); |
| 284 | } |
| 285 | |
| 286 | // ---- scanning ------------------------------------------------------------ |
| 287 | let frameBuf = 0; |
| 288 | let frameCap = 0; |
| 289 | const frameScratch = (n) => { |
| 290 | if (n > frameCap) { |
| 291 | if (frameBuf) M._ckw_free(frameBuf); |
| 292 | frameBuf = 0; |
| 293 | frameCap = 0; |
| 294 | frameBuf = alloc(n); |
| 295 | frameCap = n; |
| 296 | } |
| 297 | return frameBuf; |
| 298 | }; |
| 299 | |
| 300 | const decodeWith = (fnName, f, channels) => { |
| 301 | const n = f.width * f.height * channels; |
| 302 | const p = frameScratch(n); |
| 303 | heap().set(f.data.subarray(0, n), p); |
| 304 | const info = alloc(16); |
| 305 | try { |
| 306 | const out = M[fnName](p, f.width, f.height, 0, info); |
| 307 | if (!out) return null; |
| 308 | const bytes = takeOut(out, 'decode'); |
| 309 | const [code, version, layout] = u32s(info, 3); |
| 310 | return { |
| 311 | bytes, |
| 312 | text: readableText(bytes), |
| 313 | profile: PROFILES[code], |
| 314 | version: code <= 1 ? version : null, |
| 315 | layout: code <= 1 ? LAYOUTS[layout] : null, |
| 316 | profileCode: code, |
| 317 | }; |
| 318 | } finally { |
| 319 | M._ckw_free(info); |
| 320 | } |
| 321 | }; |
| 322 | const strip = (r) => { |
| 323 | if (!r) return null; |
| 324 | const { profileCode, ...rest } = r; // eslint-disable-line no-unused-vars |
| 325 | return rest; |
| 326 | }; |
| 327 | const decodeRGBA = (img) => decodeWith('_ckw_decode_rgba', rgbaFrame(img), 4); |
| 328 | |
| 329 | // ---- telemetry ----------------------------------------------------------- |
| 330 | const hasTelemetry = typeof M._ckw_tel_set_mode === 'function'; |
| 331 | const telCore = hasTelemetry && { |
| 332 | shouldSend: (t) => M._ckw_tel_should_send(t) !== 0, |
| 333 | takeBatch: (day) => |
| 334 | withCString(day, (d) => { |
| 335 | const p = M._ckw_tel_take_batch(d); |
| 336 | if (!p) return null; |
| 337 | const id = M._ckw_tel_last_batch_id(); |
| 338 | return { json: utf8d.decode(takeOut(p, 'telemetry batch')), id }; |
| 339 | }), |
| 340 | batchResult: (id, status, t) => M._ckw_tel_batch_result(id, status, t) === 0, |
| 341 | }; |
| 342 | const recorder = hasTelemetry |
| 343 | ? { |
| 344 | begin: (t) => M._ckw_tel_session_begin(t), |
| 345 | frame: (t, ms) => M._ckw_tel_frame(t, ms), |
| 346 | success: (t, code) => M._ckw_tel_session_success(t, code), |
| 347 | end: (t) => M._ckw_tel_session_end(t), |
| 348 | } |
| 349 | : { begin() {}, frame() {}, success() {}, end() {} }; |
| 350 | |
| 351 | const version = cstring(M._ckw_software_version()); |
| 352 | let sender = null; |
| 353 | if (hasTelemetry) { |
| 354 | withCString('web', (sdk) => |
| 355 | withCString(version, (v) => withCString('web', (platform) => M._ckw_tel_set_client(sdk, v, platform))), |
| 356 | ); |
| 357 | sender = new TelemetrySender({ |
| 358 | core: telCore, |
| 359 | fetch: options.fetch === undefined ? undefined : options.fetch, |
| 360 | endpoint: options.telemetryEndpoint, |
| 361 | now, |
| 362 | day: options.day || (() => utcDay()), |
| 363 | }); |
| 364 | installBackgroundSend(sender, options.document ?? globalThis.document, options.window ?? globalThis.window); |
| 365 | } |
| 366 | |
| 367 | const setTelemetry = (v) => { |
| 368 | const mode = telemetryMode(v); |
| 369 | if (!hasTelemetry) { |
| 370 | if (mode !== 0) throw new RangeError('this CKey build has no telemetry module; only 0 is accepted'); |
| 371 | return; |
| 372 | } |
| 373 | if (M._ckw_tel_set_mode(mode) !== 0) throw new RangeError(`telemetry must be 0, 1 or 2, got ${v}`); |
| 374 | }; |
| 375 | if (options.telemetry !== undefined) setTelemetry(options.telemetry); |
| 376 | |
| 377 | const ckey = { |
| 378 | version, |
| 379 | abiVersion: M._ckw_abi_version(), |
| 380 | |
| 381 | encode(input, opts) { |
| 382 | const bytes = payloadBytes(input); |
| 383 | const o = encodeOptions(opts); |
| 384 | const p = copyIn(bytes); |
| 385 | try { |
| 386 | const sym = M._ckw_encode(p, bytes.length, o.version, o.level, o.layout); |
| 387 | if (!sym) throw new Error(lastError('encode failed')); |
| 388 | return new CKeySymbol(sym); |
| 389 | } finally { |
| 390 | M._ckw_free(p); |
| 391 | } |
| 392 | }, |
| 393 | capacity(version, level = 'M', layout = 'standard') { |
| 394 | const o = encodeOptions({ version, level, layout }); |
| 395 | return M._ckw_scalable_capacity(o.version, o.level, o.layout); |
| 396 | }, |
| 397 | |
| 398 | renderToCanvas, |
| 399 | renderPixels, |
| 400 | renderPNG, |
| 401 | renderSVG, |
| 402 | renderPDF, |
| 403 | checkStyle, |
| 404 | certify, |
| 405 | |
| 406 | decodeImageData: (img) => strip(decodeRGBA(img)), |
| 407 | decodeGray: (img) => strip(decodeWith('_ckw_decode_gray', grayFrame(img), 1)), |
| 408 | classifyPayload, |
| 409 | |
| 410 | createScanner({ onResult, onError } = {}) { |
| 411 | return makeScanner({ decode: decodeRGBA, telemetry: recorder, now, sender }, { onResult, onError }); |
| 412 | }, |
| 413 | |
| 414 | get telemetry() { |
| 415 | return hasTelemetry ? M._ckw_tel_get_mode() : 0; |
| 416 | }, |
| 417 | set telemetry(v) { |
| 418 | setTelemetry(v); |
| 419 | }, |
| 420 | /** Local aggregate counters (modes 1 and 2; all zero in mode 0). */ |
| 421 | telemetryCounters() { |
| 422 | if (!hasTelemetry) return null; |
| 423 | const p = alloc(33 * 8); |
| 424 | try { |
| 425 | M._ckw_tel_snapshot(p); |
| 426 | const v = f64s(p, 33); |
| 427 | const hist = (o) => ({ count: v[o], sum: v[o + 1], buckets: v.slice(o + 2, o + 12) }); |
| 428 | return { |
| 429 | sessions: { started: v[0], succeeded: v[1], failed: v[2] }, |
| 430 | frames: v[3], |
| 431 | decoded: { p0: v[4], p0_1: v[5], p1: v[6], p2: v[7], p3: v[8] }, |
| 432 | time_to_decode_ms: hist(9), |
| 433 | frame_ms: hist(21), |
| 434 | }; |
| 435 | } finally { |
| 436 | M._ckw_free(p); |
| 437 | } |
| 438 | }, |
| 439 | /** |
| 440 | * Safe Actions for this app (spec/CKey-Safe-Actions.md): pin issuer keys with |
| 441 | * trust(), register actions with register(), and pass decoded text to |
| 442 | * handle(). Same-device actions run at once; cross-device ones only after |
| 443 | * request.present() and the person's approval. |
| 444 | */ |
| 445 | safeActions(appId, safeActionOptions) { |
| 446 | return new SafeActions(safeActionCore, appId, safeActionOptions); |
| 447 | }, |
| 448 | /** Attempts a telemetry send now if one is due (mode 1). Never rejects. */ |
| 449 | flushTelemetry() { |
| 450 | return sender ? sender.trySend() : Promise.resolve(null); |
| 451 | }, |
| 452 | }; |
| 453 | Object.defineProperty(ckey, '_testing', { |
| 454 | value: { |
| 455 | sender, |
| 456 | resetTelemetry: () => hasTelemetry && M._ckw_tel_reset_for_testing(), |
| 457 | setClient: () => |
| 458 | withCString('web', (sdk) => |
| 459 | withCString(version, (v) => withCString('web', (pl) => M._ckw_tel_set_client(sdk, v, pl))), |
| 460 | ), |
| 461 | }, |
| 462 | }); |
| 463 | return Object.seal(ckey); |
| 464 | } |