@quran-lab/tajweedformer 0.0.0-stage → 2.0.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 (47) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +148 -0
  3. package/README.md +121 -2
  4. package/dist/tajweedformer-opus.mjs +2 -0
  5. package/dist/tajweedformer-opus.wasm +0 -0
  6. package/dist/tajweedformer-scorer.mjs +2 -0
  7. package/dist/tajweedformer-scorer.wasm +0 -0
  8. package/dist/tajweedformer.mjs +2 -0
  9. package/dist/tajweedformer.wasm +0 -0
  10. package/docs/api.md +167 -0
  11. package/examples/browser.html +69 -0
  12. package/examples/node.mjs +37 -0
  13. package/package.json +81 -6
  14. package/web/consent/copy.js +43 -0
  15. package/web/consent/decks.js +382 -0
  16. package/web/consent/element.js +164 -0
  17. package/web/consent/index.d.ts +65 -0
  18. package/web/consent/index.js +154 -0
  19. package/web/consent/state.js +100 -0
  20. package/web/index.d.ts +4 -0
  21. package/web/index.js +10 -0
  22. package/web/platform/client.js +366 -0
  23. package/web/platform/contributor-key.js +112 -0
  24. package/web/platform/index.d.ts +114 -0
  25. package/web/platform/index.js +7 -0
  26. package/web/platform/storage.js +50 -0
  27. package/web/platform/telemetry.js +199 -0
  28. package/web/scorer.d.ts +4 -0
  29. package/web/scorer.js +10 -0
  30. package/web/sharing/encoder.js +155 -0
  31. package/web/sharing/index.d.ts +215 -0
  32. package/web/sharing/index.js +257 -0
  33. package/web/sharing/oggopus.js +294 -0
  34. package/web/sharing/outbox.js +606 -0
  35. package/web/sharing/resampler.js +122 -0
  36. package/web/sharing/uploader.js +171 -0
  37. package/web/tajweed/decode.js +213 -0
  38. package/web/tajweed/delivery.js +317 -0
  39. package/web/tajweed/engine.js +600 -0
  40. package/web/tajweed/heads.js +73 -0
  41. package/web/tajweed/index.d.ts +737 -0
  42. package/web/tajweed/index.js +12 -0
  43. package/web/tajweed/manifest.js +452 -0
  44. package/web/tajweed/node.d.ts +3 -0
  45. package/web/tajweed/node.js +59 -0
  46. package/web/tajweed/settings.js +483 -0
  47. package/web/tajweed/tajweedformer.js +369 -0
@@ -0,0 +1,366 @@
1
+ // The Quran Lab platform client for the web product: the
2
+ // developer key, an unattested app token, register, the signed config (Ed25519 over the exact bytes,
3
+ // pinned key ids, ETag, 60 s refresh while in use), the tajweed block into the model's settings,
4
+ // telemetry v2 (on by default, anonymous), issues, and the learner's sharing routes. Nothing here sits
5
+ // in the recognition path: a failing network never stops recognition.
6
+ //
7
+ // import { QuranLab } from '@quran-lab/tajweedformer/platform';
8
+ // const ql = await QuranLab.configure({ key: 'qlk_v1_...', appVersion: '1.4.0' });
9
+ // const tf = await TajweedFormer.load({ platform: ql }); // the recommended model, settings, telemetry
10
+
11
+ import { Telemetry, deviceInfo, utcDay } from './telemetry.js';
12
+ import { localState } from './storage.js';
13
+ import { QURANLAB_PUBLIC_KEY_HEX, hexToBytes, bytesToHex } from '../tajweed/manifest.js';
14
+ import { decode } from '../tajweed/decode.js';
15
+
16
+ export const SDK_VERSION = '2.0.0';
17
+ export const API_URL = 'https://api.quranlab.ai';
18
+ export const CDN_URL = 'https://cdn.quranlab.ai';
19
+ /** Where model files are served: {base}/{owner}/{repo}/{revision}/{path}. The signed config's `base`
20
+ * replaces it when present; a cdnUrl given without modelsUrl keeps the old {cdn}/hf/ path. */
21
+ export const MODELS_URL = 'https://cdn.quranlab.ai/models';
22
+ const SAFE_BASE = /^(https:\/\/[A-Za-z0-9.-]+(:\d+)?|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?)(\/[A-Za-z0-9._~\/-]*)?$/;
23
+ const REGIONAL_CDN = /^https:\/\/((weur|eeur|enam|wnam|apac|oc)\.)?cdn\.quranlab\.ai$/;
24
+ const ISSUE_KINDS = ['engine_failed', 'model_load', 'checksum_mismatch', 'signature_invalid', 'update_failed', 'fallback',
25
+ 'self_test_failed', 'audio_input', 'other'];
26
+ /** Codes after which the client stops all network calls until the next launch (error table). */
27
+ const STOP_CODES = new Set(['invalid_key', 'unauthorized', 'developer_not_approved', 'not_a_member', 'key_type_mismatch',
28
+ 'origin_not_allowed', 'app_not_allowed', 'app_blocked', 'environment_mismatch', 'sdk_too_old']);
29
+
30
+ /** A platform failure: { status, code } from the API's {"error","message"} body. */
31
+ export class PlatformError extends Error {
32
+ constructor(status, code, message) { super(`${status} ${code}${message ? ': ' + message : ''}`); this.name = 'PlatformError'; this.status = status; this.code = code; }
33
+ }
34
+
35
+ const subtle = () => globalThis.crypto.subtle;
36
+ const b64uToBytes = (s) => {
37
+ const t = s.replace(/-/g, '+').replace(/_/g, '/');
38
+ const bin = atob(t + '='.repeat((4 - (t.length % 4)) % 4));
39
+ return Uint8Array.from(bin, (c) => c.charCodeAt(0));
40
+ };
41
+
42
+ /** The key id of a raw 32-byte Ed25519 public key: the first 8 bytes of its SHA-256, hex. */
43
+ export async function keyId(raw) {
44
+ const b = typeof raw === 'string' ? hexToBytes(raw) : raw;
45
+ return bytesToHex(new Uint8Array(await subtle().digest('SHA-256', b)).subarray(0, 8));
46
+ }
47
+
48
+ /**
49
+ * Verify a signed config response (PLATFORM_CLIENT.md section 3): the key id pinned, Ed25519 over
50
+ * the exact body bytes, v 1, not expired. Returns the parsed config, or throws PlatformError
51
+ * 'signature_invalid' / 'config_expired'.
52
+ * @param {Uint8Array} body the body bytes as received
53
+ * @param {string|null} sigHeader x-qlab-signature ("ed25519=<base64url>")
54
+ * @param {string|null} kid x-qlab-key-id
55
+ * @param {Array<string|Uint8Array>} trustedKeys raw public keys (hex or bytes)
56
+ */
57
+ export async function verifySignedConfig(body, sigHeader, kid, trustedKeys, now = Date.now()) {
58
+ if (!sigHeader || !sigHeader.startsWith('ed25519=') || !kid) throw new PlatformError(0, 'signature_invalid', 'no signature');
59
+ let sig;
60
+ try { sig = b64uToBytes(sigHeader.slice(8)); } catch (_) { throw new PlatformError(0, 'signature_invalid', 'bad signature encoding'); }
61
+ let ok = false;
62
+ for (const k of trustedKeys) {
63
+ if ((await keyId(k)) !== kid) continue;
64
+ const key = await subtle().importKey('raw', typeof k === 'string' ? hexToBytes(k) : k, { name: 'Ed25519' }, false, ['verify']);
65
+ if (await subtle().verify({ name: 'Ed25519' }, key, sig, body)) { ok = true; break; }
66
+ }
67
+ if (!ok) throw new PlatformError(0, 'signature_invalid', `the config does not verify with a pinned key (kid ${kid})`);
68
+ const cfg = JSON.parse(new TextDecoder().decode(body));
69
+ if (cfg.v !== 1) throw new PlatformError(0, 'signature_invalid', `config v ${cfg.v}`);
70
+ if (!(typeof cfg.expires_at === 'number' && cfg.expires_at * 1000 > now)) throw new PlatformError(0, 'config_expired', 'the config has expired');
71
+ return cfg;
72
+ }
73
+
74
+ /**
75
+ * One configured client. Use QuranLab.configure(...).
76
+ */
77
+ export class QuranLab {
78
+ /**
79
+ * @param {object} o
80
+ * @param {string} o.key the developer key qlk_v1_... (a web key in browsers; a desktop key in Node)
81
+ * @param {string} [o.appVersion]
82
+ * @param {string} [o.appName]
83
+ * @param {string} [o.platform] 'web' (browsers, default), or 'linux' / 'windows' / 'web' in Node
84
+ * @param {string} [o.bundleId] web: the page's origin (default location.origin); desktop: the app id
85
+ * @param {string} [o.origin] Node with a web key: the Origin header to send
86
+ * @param {'on'|'off'} [o.analytics='on'] the one developer opt-out; never a prompt
87
+ * @param {boolean} [o.issues=true]
88
+ * @param {boolean} [o.contributions=false] recitation sharing (asks the learner first)
89
+ * @param {'general'|'children'} [o.audience]
90
+ * @param {number} [o.sharingBitrate] Opus bit/s for shared sessions (default 192000, SHARING_BITRATE_DEFAULT)
91
+ * @param {object} [o.state] persistence (default localStorage; Node: dirState(dir))
92
+ * @param {Array<string|Uint8Array>} [o.trustedKeys] pinned config keys (default Quran Lab's)
93
+ * @param {string} [o.apiUrl] [o.modelsUrl] [o.cdnUrl] [o.fetch] [o.clock] o.modelsUrl: model files (default the
94
+ * signed config's base, else cdn.quranlab.ai/models); o.cdnUrl alone: the old {cdn}/hf/ path
95
+ */
96
+ constructor(o) {
97
+ if (!o || typeof o.key !== 'string' || !/^qlk_v1_/.test(o.key)) throw new TypeError('QuranLab: a developer key qlk_v1_... is needed');
98
+ const inBrowser = typeof globalThis.window !== 'undefined' && !!globalThis.location;
99
+ this.key = o.key;
100
+ this.apiUrl = (o.apiUrl || API_URL).replace(/\/$/, '');
101
+ this.cdnUrl = (o.cdnUrl || CDN_URL).replace(/\/$/, '');
102
+ this.modelsUrl = o.modelsUrl ? o.modelsUrl.replace(/\/+$/, '') : null;
103
+ this.legacyCdn = !!o.cdnUrl && !o.modelsUrl;
104
+ this.platform = o.platform || 'web';
105
+ this.bundleId = o.bundleId || (inBrowser ? globalThis.location.origin : null);
106
+ if (!this.bundleId) throw new TypeError('QuranLab: bundleId is needed outside a browser (the origin for a web key, the app id for a desktop key)');
107
+ this.origin = o.origin || null; // Node only: browsers set Origin themselves
108
+ this.appVersion = o.appVersion || '0';
109
+ this.appName = o.appName || null;
110
+ this.fetch = o.fetch || globalThis.fetch.bind(globalThis);
111
+ this.clock = o.clock || Date.now;
112
+ this.state = o.state || localState();
113
+ this.trustedKeys = o.trustedKeys || [QURANLAB_PUBLIC_KEY_HEX];
114
+ this.analyticsOn = o.analytics !== 'off';
115
+ this.issuesOn = o.issues !== false;
116
+ this.contributions = !!o.contributions;
117
+ this.audience = o.audience || 'general';
118
+ this.sharingBitrate = o.sharingBitrate || null;
119
+ this.stopped = null; // the code that stopped the network this launch
120
+ this.config = null;
121
+ this._token = null;
122
+ this._timer = null;
123
+ this.telemetry = new Telemetry({ state: this.state, product: 'web', platform: this.platform, sdkVersion: SDK_VERSION,
124
+ appVersion: this.appVersion, device: deviceInfo(), clock: this.clock, enabled: this.analyticsOn });
125
+ // the stored signed config, if still valid, until the network answers
126
+ const saved = this.state.get('config');
127
+ if (saved && saved.cfg && saved.cfg.expires_at * 1000 > this.clock()) this.config = saved.cfg;
128
+ if (this.state.get('bucket') === null) this.state.set('bucket', Math.floor(Math.random() * 100));
129
+ }
130
+
131
+ /** Configure and run the launch sequence (token, register, config, pending telemetry and issues).
132
+ * Never throws for network trouble; check `ql.stopped` and `ql.lastError`. */
133
+ static async configure(o) {
134
+ const ql = new QuranLab(o);
135
+ await ql.launch();
136
+ return ql;
137
+ }
138
+
139
+ get flags() { return (this.config && this.config.flags) || { analytics: true, issues: true, contributions: false }; }
140
+
141
+ // ---- HTTP ----
142
+ async _call(method, path, { body = null, auth = 'token', headers = {}, raw = false } = {}) {
143
+ if (this.stopped) throw new PlatformError(0, this.stopped, 'stopped for this launch');
144
+ const h = { ...headers };
145
+ if (this.origin) h.origin = this.origin;
146
+ if (auth === 'key') h['x-qlab-key'] = this.key;
147
+ else if (auth === 'token') h.authorization = `Bearer ${await this.token()}`;
148
+ if (body !== null) h['content-type'] = 'application/json';
149
+ const r = await this.fetch(this.apiUrl + path, { method, headers: h, body: body === null ? undefined : JSON.stringify(body) });
150
+ if (raw) return r;
151
+ if (r.status >= 400) {
152
+ let e = {};
153
+ try { e = await r.json(); } catch (_) { /* no body */ }
154
+ const err = new PlatformError(r.status, e.error || 'http_' + r.status, e.message || '');
155
+ if (STOP_CODES.has(err.code)) this.stopped = err.code;
156
+ if (err.code === 'invalid_token' && auth === 'token' && !headers['x-retried']) {
157
+ this._token = null; this.state.set('token', null);
158
+ return this._call(method, path, { body, auth, headers: { ...headers, 'x-retried': '1' } }).catch((e2) => { throw e2; });
159
+ }
160
+ throw err;
161
+ }
162
+ return r.status === 204 || r.status === 304 ? null : r.json();
163
+ }
164
+
165
+ /** An unattested app token (24 h), renewed when under 1 h is left. */
166
+ async token() {
167
+ const now = this.clock() / 1000;
168
+ const t = this._token || this.state.get('token');
169
+ if (t && t.exp - now > 3600) { this._token = t; return t.token; }
170
+ const r = await this._call('POST', '/v1/attest', { auth: 'key', body: { platform: this.platform, bundle_id: this.bundleId } });
171
+ this._token = { token: r.token, exp: now + (r.expires_in || 86400), attested: !!r.attested };
172
+ this.state.set('token', this._token);
173
+ return r.token;
174
+ }
175
+
176
+ // ---- launch ----
177
+ async launch() {
178
+ const steps = [() => this.register(), () => this.refreshConfig(), () => this.flush()];
179
+ for (const s of steps) {
180
+ try { await s(); } catch (e) { this.lastError = e; if (this.stopped) break; }
181
+ }
182
+ }
183
+
184
+ /** POST /v1/register, once per launch. */
185
+ async register() {
186
+ if (this._registered) return this._registered;
187
+ const first = this.state.get('installed') === null;
188
+ if (first) this.state.set('installed', true);
189
+ const pendingNew = this.state.get('register.pending_new') === true;
190
+ const newInstall = first || pendingNew;
191
+ this.state.set('register.pending_new', newInstall);
192
+ const body = { platform: this.platform, bundle_id: this.bundleId, app_version: this.appVersion, sdk_version: SDK_VERSION, new_install: newInstall };
193
+ if (this.appName) body.app_name = this.appName;
194
+ if (this.modelRevision) body.model_revision = this.modelRevision;
195
+ this._registered = await this._call('POST', '/v1/register', { body });
196
+ this.state.set('register.pending_new', null);
197
+ return this._registered;
198
+ }
199
+
200
+ /**
201
+ * GET /v1/config with If-None-Match; verifies and stores it. A failure keeps the last good config.
202
+ * Returns the config in force (or null).
203
+ */
204
+ async refreshConfig() {
205
+ const saved = this.state.get('config');
206
+ const nearExpiry = saved && saved.cfg && saved.cfg.expires_at * 1000 - this.clock() < 3600_000;
207
+ const q = new URLSearchParams({ bucket: String(this.state.get('bucket')), sdk: SDK_VERSION });
208
+ const headers = {};
209
+ if (saved && saved.etag && !nearExpiry) headers['if-none-match'] = saved.etag;
210
+ const r = await this._call('GET', `/v1/config?${q}`, { headers, raw: true });
211
+ if (r.status === 304 && saved) { this.config = saved.cfg; return this.config; }
212
+ if (r.status >= 400) {
213
+ let e = {};
214
+ try { e = await r.json(); } catch (_) { /* none */ }
215
+ const err = new PlatformError(r.status, e.error || 'http_' + r.status, e.message);
216
+ if (STOP_CODES.has(err.code)) this.stopped = err.code;
217
+ throw err;
218
+ }
219
+ const body = new Uint8Array(await r.arrayBuffer());
220
+ let cfg;
221
+ try {
222
+ cfg = await verifySignedConfig(body, r.headers.get('x-qlab-signature'), r.headers.get('x-qlab-key-id'), this.trustedKeys, this.clock());
223
+ } catch (e) {
224
+ this.reportIssue('signature_invalid', 'config:' + e.code);
225
+ throw e;
226
+ }
227
+ this.config = cfg;
228
+ this.state.set('config', { cfg, etag: r.headers.get('etag'), sig: r.headers.get('x-qlab-signature'), kid: r.headers.get('x-qlab-key-id') });
229
+ if (cfg.flags && cfg.flags.analytics === false) this.telemetry.clear();
230
+ this.telemetry.enabled = this.analyticsOn && !(cfg.flags && cfg.flags.analytics === false);
231
+ for (const tf of this._models || []) tf.setRemoteConfig(cfg);
232
+ return cfg;
233
+ }
234
+
235
+ /** Refresh the config every `ms` (60 s) while the app is in use; call stop() when it is not. */
236
+ startRefresh(ms = 60_000) {
237
+ this.stopRefresh();
238
+ this._timer = setInterval(() => { this.refreshConfig().catch(() => {}); }, ms);
239
+ if (this._timer && typeof this._timer.unref === 'function') this._timer.unref();
240
+ }
241
+ stopRefresh() { if (this._timer) clearInterval(this._timer); this._timer = null; }
242
+
243
+ /** Where the recommended model lives on the CDN: { url, headers } for TajweedFormer.load. */
244
+ async modelSource() {
245
+ const cfg = this.config || (await this.refreshConfig().catch(() => null));
246
+ const rec = cfg && cfg.recommended;
247
+ if (!rec || !rec.revision) throw new PlatformError(0, 'no_model', 'the config recommends no model for this app and SDK');
248
+ const enc = (p) => p.split('/').map(encodeURIComponent).join('/');
249
+ this.modelRevision = `${rec.model_id || 'v4.0'}+${rec.revision}`.slice(0, 64);
250
+ // the base: the app's modelsUrl, else the signed config's base, else the app's cdnUrl (old /hf/ path), else
251
+ // cdn.quranlab.ai/models. A bare cdn.quranlab.ai base always means the /hf/ path.
252
+ const signed = typeof cfg.base === 'string' && SAFE_BASE.test(cfg.base) ? cfg.base.replace(/\/+$/, '') : null;
253
+ let base = this.modelsUrl || signed || (this.legacyCdn ? this.cdnUrl + '/hf' : MODELS_URL);
254
+ if (REGIONAL_CDN.test(base)) base += '/hf';
255
+ return { url: `${base}/${enc(rec.model)}/${enc(rec.revision)}/${enc(rec.folder || 'sdk/' + rec.model_id)}/`,
256
+ headers: { 'x-qlab-key': this.key, ...(this.origin ? { origin: this.origin } : {}) } };
257
+ }
258
+
259
+ /** Called by TajweedFormer.load({ platform }): the portal's settings and the telemetry hooks. */
260
+ attach(tf) {
261
+ this._models = (this._models || []).filter((x) => x._eng);
262
+ this._models.push(tf);
263
+ if (this.config) tf.setRemoteConfig(this.config);
264
+ tf._telemetry = this;
265
+ this.telemetry.modelLoaded(tf, tf.loadMs || 0);
266
+ if (tf.delivery) {
267
+ this.telemetry.download({ revision: this.modelRevision || 'unknown', profile: 'web-cpu', bytes: tf.delivery.bytesDownloaded,
268
+ success: true, resumed: tf.delivery.rangesResumed > 0 });
269
+ }
270
+ }
271
+
272
+ // hooks the model calls (tajweedformer.js)
273
+ streamStarted(tf, s) {
274
+ const self = this;
275
+ const timings = [];
276
+ let samples = 0, firstAt = null;
277
+ const t0 = this.clock();
278
+ const push = s.pushAudio.bind(s), finish = s.finish.bind(s), reset = s.reset.bind(s), free = s.free.bind(s);
279
+ const timed = (fn, n) => { const a = performance.now(); const k = fn(); const ms = performance.now() - a; if (k > 0) for (let i = 0; i < k; i++) timings.push(ms / k); return k; };
280
+ const end = (aborted) => {
281
+ if (!samples) return;
282
+ self.telemetry.session(tf, { seconds: samples / tf.sampleRate, chunkMs: timings.splice(0), aborted, firstMs: firstAt });
283
+ try { self.telemetry.decoded(tf, decode(s.heads(), {})); } catch (_) { /* freed */ }
284
+ samples = 0;
285
+ };
286
+ s.pushAudio = (pcm) => { if (firstAt === null) firstAt = self.clock() - t0; samples += pcm.length; return timed(() => push(pcm)); };
287
+ s.finish = () => { const k = timed(finish); end(false); return k; };
288
+ s.reset = () => { end(true); return reset(); };
289
+ s.free = () => { end(true); return free(); };
290
+ if (this.sharing && typeof this.sharing.streamStarted === 'function') this.sharing.streamStarted(tf, s);
291
+ }
292
+ scored(tf, result) { this.telemetry.scored(tf, result); }
293
+
294
+ // ---- telemetry and issues ----
295
+ /** Send every completed day (202 drop, 400 drop, 401 renew once, else keep) and queued issues. */
296
+ async flush() {
297
+ let sent = 0;
298
+ if (this.telemetry.enabled && this.flags.analytics !== false) {
299
+ for (const p of this.telemetry.pending()) {
300
+ try { await this._call('POST', '/v1/events', { body: p }); this.telemetry.drop(p.day); sent++; } catch (e) {
301
+ if (e.status === 400 || e.status === 413) this.telemetry.drop(p.day); else break;
302
+ }
303
+ }
304
+ } else if (this.flags.analytics === false) this.telemetry.clear();
305
+ const issues = this.state.get('issues.queue') || [];
306
+ const keep = [];
307
+ for (const i of issues) {
308
+ if (!this.issuesOn || this.flags.issues === false) break;
309
+ try { await this._call('POST', '/v1/issues', { body: i }); } catch (e) { if (!(e.status === 400)) keep.push(i); }
310
+ }
311
+ this.state.set('issues.queue', this.flags.issues === false ? [] : keep);
312
+ return sent;
313
+ }
314
+
315
+ /** Queue an issue report (no free text, no paths); one per kind+code per day. */
316
+ reportIssue(kind, code, frames = []) {
317
+ if (!this.issuesOn) return false;
318
+ const k = ISSUE_KINDS.includes(kind) ? kind : 'other';
319
+ const c = String(code).replace(/[^A-Za-z0-9_.:-]/g, '_').slice(0, 64) || 'unknown';
320
+ const day = utcDay(this.clock());
321
+ const seen = this.state.get('issues.seen') || {};
322
+ const sig = `${day}|${k}|${c}`;
323
+ if (seen[sig]) return false;
324
+ for (const s of Object.keys(seen)) if (!s.startsWith(day)) delete seen[s];
325
+ seen[sig] = 1;
326
+ this.state.set('issues.seen', seen);
327
+ const ua = (globalThis.navigator && globalThis.navigator.userAgent) || '';
328
+ const q = this.state.get('issues.queue') || [];
329
+ q.push({ day, platform: this.platform, sdk_version: SDK_VERSION, app_version: this.appVersion,
330
+ os_version: String(this.telemetry.device.os_major), device_class: this.telemetry.device.class, backend: 'exact',
331
+ ...(this.modelRevision ? { revision: this.modelRevision } : {}), kind: k, code: c,
332
+ ...(frames.length ? { frames: frames.slice(0, 16).map((f) => String(f).slice(0, 80)) } : {}) });
333
+ this.state.set('issues.queue', q.slice(-20));
334
+ void ua;
335
+ return true;
336
+ }
337
+
338
+ // ---- the learner's sharing routes (section 9) ----
339
+ /** The contribution code (32 hex), created on first use, stored per origin. */
340
+ contributor() {
341
+ let c = this.state.get('contributor');
342
+ if (!c) {
343
+ const b = new Uint8Array(16);
344
+ globalThis.crypto.getRandomValues(b);
345
+ c = bytesToHex(b);
346
+ this.state.set('contributor', c);
347
+ }
348
+ return c;
349
+ }
350
+ resetContributor() { this.state.set('contributor', null); }
351
+
352
+ /** GET /v1/contributions/summary (real numbers only; null when it fails). */
353
+ async contributionSummary() {
354
+ try { return await this._call('GET', '/v1/contributions/summary', { headers: { 'x-qlab-contributor': this.contributor() } }); } catch (_) { return null; }
355
+ }
356
+
357
+ /** Whether this install may upload under the signed config (flags.contributions and upload_trust). */
358
+ sharingAllowed() {
359
+ const f = this.flags;
360
+ if (!this.contributions || f.contributions !== true) return false;
361
+ // owner decision (11-07): web and desktop upload at the lower trust tier (`capped`, attested=false);
362
+ // an absent upload_trust entry means that tier; `off` or `attested` (which they cannot meet) mean no
363
+ const trust = (f.upload_trust && f.upload_trust[this.platform]) || 'capped';
364
+ return trust === 'capped';
365
+ }
366
+ }
@@ -0,0 +1,112 @@
1
+ // The learner's sharing key: a per-contributor P-256 key pair made with WebCrypto as
2
+ // NON-EXTRACTABLE (the private half can sign but can never be read by script, including the app's),
3
+ // stored in IndexedDB (CryptoKey objects are structured-cloneable), bound to the contributor id on the
4
+ // first upload. Deletion of what the learner shared is a challenge-response signed with it.
5
+ //
6
+ // The app gets: the public key (to send on the first upload), sign(bytes) for the SDK's own deletion
7
+ // request, and nothing else. There is no export of the private key and no call that deletes for anyone
8
+ // but this install's contributor.
9
+
10
+ const DB = 'quranlab-sharing-keys-v1';
11
+ const ALG = { name: 'ECDSA', namedCurve: 'P-256' };
12
+ const SIGN = { name: 'ECDSA', hash: 'SHA-256' };
13
+
14
+ function idb() {
15
+ if (!globalThis.indexedDB) return null;
16
+ return new Promise((res, rej) => {
17
+ const q = globalThis.indexedDB.open(DB, 1);
18
+ q.onupgradeneeded = () => q.result.createObjectStore('keys');
19
+ q.onsuccess = () => res(q.result);
20
+ q.onerror = () => rej(q.error);
21
+ });
22
+ }
23
+ async function idbOp(mode, fn) {
24
+ const d = await idb();
25
+ if (!d) return undefined;
26
+ return new Promise((res, rej) => {
27
+ const t = d.transaction('keys', mode);
28
+ const r = fn(t.objectStore('keys'));
29
+ t.oncomplete = () => res(r && 'result' in r ? r.result : undefined);
30
+ t.onerror = () => rej(t.error);
31
+ });
32
+ }
33
+
34
+ /** In-memory fallback (Node, or no IndexedDB): the key lives as long as the process. */
35
+ const memory = new Map();
36
+
37
+ /**
38
+ * The key pair of a contributor id, created on first use. Returns { contributor, publicKeyRaw
39
+ * (65-byte uncompressed P-256 point), publicKeySpki (DER), sign(bytes) -> 64-byte r||s }.
40
+ */
41
+ export async function contributorKey(contributor, { create = true } = {}) {
42
+ let pair = memory.get(contributor) || (await idbOp('readonly', (s) => s.get(contributor)).catch(() => undefined));
43
+ if (!pair) {
44
+ if (!create) return null;
45
+ pair = await globalThis.crypto.subtle.generateKey(ALG, false, ['sign', 'verify']);
46
+ memory.set(contributor, pair);
47
+ await idbOp('readwrite', (s) => s.put(pair, contributor)).catch(() => {});
48
+ }
49
+ const subtle = globalThis.crypto.subtle;
50
+ const raw = new Uint8Array(await subtle.exportKey('raw', pair.publicKey));
51
+ const spki = new Uint8Array(await subtle.exportKey('spki', pair.publicKey));
52
+ return {
53
+ contributor,
54
+ extractable: pair.privateKey.extractable, // always false
55
+ publicKeyRaw: raw,
56
+ publicKeySpki: spki,
57
+ async sign(bytes) { return new Uint8Array(await subtle.sign(SIGN, pair.privateKey, bytes)); },
58
+ };
59
+ }
60
+
61
+ /** Forget a contributor's key (after a deletion, or when sharing is turned off for good). */
62
+ export async function forgetContributorKey(contributor) {
63
+ memory.delete(contributor);
64
+ await idbOp('readwrite', (s) => s.delete(contributor)).catch(() => {});
65
+ }
66
+
67
+ // ---- the recovery code (PLATFORM_CLIENT.md section 9.1) ----
68
+ const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
69
+
70
+ /** 16 bytes as 26 Crockford base32 characters, most significant bit first (the last character carries
71
+ * 3 bits and two zero bits), shown in groups of 4 separated by "-". */
72
+ export function recoveryCodeFromBytes(bytes) {
73
+ if (bytes.length !== 16) throw new RangeError('a recovery code is 16 bytes');
74
+ let bits = 0, acc = 0, out = '';
75
+ for (const b of bytes) {
76
+ acc = (acc << 8) | b; bits += 8;
77
+ while (bits >= 5) { bits -= 5; out += CROCKFORD[(acc >> bits) & 31]; }
78
+ acc &= (1 << bits) - 1;
79
+ }
80
+ if (bits) out += CROCKFORD[(acc << (5 - bits)) & 31];
81
+ return out.match(/.{1,4}/g).join('-');
82
+ }
83
+
84
+ /** The normalised form the hash is taken of: upper case, spaces and "-" removed, O as 0, I and L as 1. */
85
+ export function normaliseRecoveryCode(code) {
86
+ return String(code).toUpperCase().replace(/[\s-]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1');
87
+ }
88
+
89
+ /** recovery_hash: lower-case hex SHA-256 of the normalised code. */
90
+ export async function recoveryHash(code) {
91
+ const d = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(normaliseRecoveryCode(code)));
92
+ return Array.from(new Uint8Array(d), (x) => x.toString(16).padStart(2, '0')).join('');
93
+ }
94
+
95
+ /** A fresh recovery code (16 random bytes). */
96
+ export function newRecoveryCode() {
97
+ const b = new Uint8Array(16);
98
+ globalThis.crypto.getRandomValues(b);
99
+ return recoveryCodeFromBytes(b);
100
+ }
101
+
102
+ /** The exact bytes a deletion signs (LF line ends, final LF). */
103
+ export function deletionMessage({ app, contributor, nonce, timestamp }) {
104
+ return new TextEncoder().encode(`quranlab-contribution-delete 1\napp ${app}\ncontributor ${contributor}\nnonce ${nonce}\ntimestamp ${timestamp}\naction delete-all\n`);
105
+ }
106
+
107
+ /** base64url without padding. */
108
+ export function b64url(bytes) {
109
+ let s = '';
110
+ for (const x of bytes) s += String.fromCharCode(x);
111
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
112
+ }
@@ -0,0 +1,114 @@
1
+ // Types for @quran-lab/tajweedformer/platform: the Quran Lab platform client.
2
+
3
+ export declare const SDK_VERSION: '2.0.0';
4
+ export declare const API_URL: string;
5
+ export declare const CDN_URL: string;
6
+
7
+ /** Persistence for small JSON state. */
8
+ export interface StateStore { kind: string; get(key: string): any; set(key: string, value: unknown): void }
9
+ export declare function memoryState(): StateStore;
10
+ /** localStorage (per origin), falling back to memory. */
11
+ export declare function localState(prefix?: string): StateStore;
12
+ /** Node: a directory of JSON files (0600). */
13
+ export declare function dirState(dir: string): Promise<StateStore>;
14
+
15
+ export declare class PlatformError extends Error { readonly status: number; readonly code: string }
16
+
17
+ export interface SignedConfig {
18
+ v: 1; app: string; channel: string; issued_at: number; expires_at: number;
19
+ models: Record<string, { revision: string; source: string; model_id?: string; folder?: string }>;
20
+ recommended: { model: string; model_id: string; folder: string; revision: string; source: string } | null;
21
+ denylist: Record<string, string>[];
22
+ flags: { analytics?: boolean; issues?: boolean; contributions?: boolean; upload_trust?: Record<string, 'off' | 'capped' | 'attested'>; [k: string]: unknown };
23
+ tajweed?: unknown;
24
+ /** where model files are served (qlab-api signs it): {base}/{model}/{revision}/{folder}/ */
25
+ base?: string;
26
+ }
27
+
28
+ export interface QuranLabOptions {
29
+ /** the developer key qlk_v1_... (a web key in browsers, origin-locked; a desktop key from Node) */
30
+ key: string;
31
+ appVersion?: string;
32
+ appName?: string;
33
+ /** 'web' in browsers; in Node 'web' (with origin), 'linux' or 'windows' */
34
+ platform?: 'web' | 'linux' | 'windows';
35
+ /** web: the page's origin (default location.origin); desktop: the reverse-DNS or package id */
36
+ bundleId?: string;
37
+ /** Node with a web key: the Origin header to send */
38
+ origin?: string;
39
+ /** telemetry: on by default, anonymous; 'off' is the one developer opt-out (never a prompt) */
40
+ analytics?: 'on' | 'off';
41
+ issues?: boolean;
42
+ /** recitation sharing (the SDK asks the learner first; see @quran-lab/tajweedformer/consent) */
43
+ contributions?: boolean;
44
+ audience?: 'general' | 'children';
45
+ state?: StateStore;
46
+ /** pinned Ed25519 config keys (default: Quran Lab's) */
47
+ trustedKeys?: (string | Uint8Array)[];
48
+ apiUrl?: string;
49
+ /** model files: default the signed config's base, else https://cdn.quranlab.ai/models */
50
+ modelsUrl?: string;
51
+ /** given alone (no modelsUrl): model files from the old {cdnUrl}/hf/ path */
52
+ cdnUrl?: string;
53
+ fetch?: typeof fetch;
54
+ clock?: () => number;
55
+ }
56
+
57
+ export declare class QuranLab {
58
+ constructor(o: QuranLabOptions);
59
+ /** configure and run the launch sequence (token, register, config, pending telemetry and issues);
60
+ * never throws for network trouble */
61
+ static configure(o: QuranLabOptions): Promise<QuranLab>;
62
+ readonly config: SignedConfig | null;
63
+ readonly flags: SignedConfig['flags'];
64
+ /** the error-table code that stopped the network for this launch, or null */
65
+ readonly stopped: string | null;
66
+ readonly lastError?: Error;
67
+ readonly telemetry: Telemetry;
68
+ token(): Promise<string>;
69
+ launch(): Promise<void>;
70
+ register(): Promise<{ ok: boolean; app_id: string; store_status: string }>;
71
+ refreshConfig(): Promise<SignedConfig | null>;
72
+ /** refresh every ms (60 s) while the app is in use */
73
+ startRefresh(ms?: number): void;
74
+ stopRefresh(): void;
75
+ modelSource(): Promise<{ url: string; headers: Record<string, string> }>;
76
+ flush(): Promise<number>;
77
+ reportIssue(kind: string, code: string, frames?: string[]): boolean;
78
+ contributor(): string;
79
+ resetContributor(): void;
80
+ contributionSummary(): Promise<{ you: { sessions: number; minutes: number }; app: { contributors: number; minutes: number } | null;
81
+ all: { contributors: number; minutes: number } } | null>;
82
+ sharingAllowed(): boolean;
83
+ }
84
+
85
+ export declare function verifySignedConfig(body: Uint8Array, signatureHeader: string | null, keyId: string | null,
86
+ trustedKeys: (string | Uint8Array)[], now?: number): Promise<SignedConfig>;
87
+ export declare function keyId(rawPublicKey: string | Uint8Array): Promise<string>;
88
+
89
+ export declare class Telemetry {
90
+ enabled: boolean;
91
+ /** completed days as wire payloads (v2) */
92
+ pending(): object[];
93
+ clear(): void;
94
+ }
95
+ export declare function bucket(kind: string, value: number): string;
96
+ export declare function utcDay(t?: number): string;
97
+ export declare function deviceInfo(): { class: string; soc?: string; os_major: number; ram_tier?: string };
98
+
99
+ export interface ContributorKey {
100
+ contributor: string;
101
+ /** always false: the private half never leaves WebCrypto */
102
+ extractable: false;
103
+ publicKeyRaw: Uint8Array;
104
+ publicKeySpki: Uint8Array;
105
+ sign(bytes: Uint8Array): Promise<Uint8Array>;
106
+ }
107
+ export declare function contributorKey(contributor: string, opts?: { create?: boolean }): Promise<ContributorKey | null>;
108
+ export declare function forgetContributorKey(contributor: string): Promise<void>;
109
+ export declare function recoveryCodeFromBytes(bytes: Uint8Array): string;
110
+ export declare function normaliseRecoveryCode(code: string): string;
111
+ export declare function recoveryHash(code: string): Promise<string>;
112
+ export declare function newRecoveryCode(): string;
113
+ export declare function deletionMessage(m: { app: string; contributor: string; nonce: string; timestamp: number }): Uint8Array;
114
+ export declare function b64url(bytes: Uint8Array): string;
@@ -0,0 +1,7 @@
1
+ // @quran-lab/tajweedformer/platform: the Quran Lab platform client:
2
+ // key, token, register, the signed config, telemetry v2 (on by default, anonymous), issues.
3
+ // import { QuranLab } from '@quran-lab/tajweedformer/platform';
4
+ export { QuranLab, PlatformError, verifySignedConfig, keyId, SDK_VERSION, API_URL, CDN_URL } from './client.js';
5
+ export { Telemetry, bucket, utcDay, deviceInfo } from './telemetry.js';
6
+ export { memoryState, localState, dirState } from './storage.js';
7
+ export { contributorKey, forgetContributorKey, recoveryCodeFromBytes, normaliseRecoveryCode, recoveryHash, newRecoveryCode, deletionMessage, b64url } from './contributor-key.js';
@@ -0,0 +1,50 @@
1
+ // Small persistent state for the platform client (tokens, the signed config, telemetry days, the
2
+ // sharing state, the bucket, the first-launch flag): JSON values by key.
3
+ // browsers: localStorage (per origin), wrapped in try/catch (private modes can throw)
4
+ // Node: a JSON file per key in a directory (nodeStateStore in ../tajweed/node.js style, here)
5
+ // tests: memoryState()
6
+
7
+ /** In memory. */
8
+ export function memoryState() {
9
+ const m = new Map();
10
+ return {
11
+ kind: 'memory',
12
+ get(k) { return m.has(k) ? JSON.parse(m.get(k)) : null; },
13
+ set(k, v) { if (v === null || v === undefined) m.delete(k); else m.set(k, JSON.stringify(v)); },
14
+ };
15
+ }
16
+
17
+ /** localStorage, prefixed "quranlab.". Falls back to memory when storage throws. */
18
+ export function localState(prefix = 'quranlab.') {
19
+ const mem = memoryState();
20
+ const ls = (() => { try { return globalThis.localStorage || null; } catch (_) { return null; } })();
21
+ return {
22
+ kind: ls ? 'localStorage' : 'memory',
23
+ get(k) {
24
+ try { const v = ls ? ls.getItem(prefix + k) : null; return v === null ? mem.get(k) : JSON.parse(v); } catch (_) { return mem.get(k); }
25
+ },
26
+ set(k, v) {
27
+ mem.set(k, v);
28
+ try { if (ls) { if (v === null || v === undefined) ls.removeItem(prefix + k); else ls.setItem(prefix + k, JSON.stringify(v)); } } catch (_) { /* kept in memory */ }
29
+ },
30
+ };
31
+ }
32
+
33
+ /** A directory of JSON files (Node). Synchronous on purpose: tiny files, called rarely. */
34
+ export async function dirState(dir) {
35
+ const fs = await import('node:fs');
36
+ const path = await import('node:path');
37
+ fs.mkdirSync(dir, { recursive: true });
38
+ const file = (k) => path.join(dir, encodeURIComponent(k) + '.json');
39
+ return {
40
+ kind: 'directory',
41
+ get(k) { try { return JSON.parse(fs.readFileSync(file(k), 'utf8')); } catch (_) { return null; } },
42
+ set(k, v) {
43
+ const f = file(k);
44
+ if (v === null || v === undefined) { fs.rmSync(f, { force: true }); return; }
45
+ const tmp = `${f}.${process.pid}.tmp`;
46
+ fs.writeFileSync(tmp, JSON.stringify(v), { mode: 0o600 });
47
+ fs.renameSync(tmp, f);
48
+ },
49
+ };
50
+ }