@adukilabs/sdk 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/dpop.js ADDED
@@ -0,0 +1,156 @@
1
+ // DPoP (RFC 9449; ADK-AUTH-001 section 10, aduki-kt Dpop.kt) with ES256 and a
2
+ // non-extractable WebCrypto P-256 key. The private key never leaves the
3
+ // key store: it can only sign. It is persisted as a CryptoKey in IndexedDB
4
+ // (or any storage that structured-clones keys), or kept in memory only.
5
+ //
6
+ // - A request with no Authorization (sign-in, refresh) gets a proof without `ath`.
7
+ // - A request whose token is bound to this key (`cnf.jkt` equals the thumbprint)
8
+ // is sent as `Authorization: DPoP <token>` with a proof carrying `ath`.
9
+ // - Any other credential is left untouched.
10
+ // - A DPoP-Nonce from the server is remembered per origin; a 401 or 400
11
+ // carrying a nonce is retried once with it.
12
+ import { claims, encode, encodeText } from './base64.js';
13
+ import { ProtocolError } from './errors.js';
14
+ const ALG = { name: 'ECDSA', namedCurve: 'P-256' };
15
+ const KEY = 'dpop.key';
16
+ export class Dpop {
17
+ #pair;
18
+ #jwk;
19
+ #thumbprint;
20
+ #clock;
21
+ #nonces = new Map();
22
+ /** Use `Dpop.load` or `Dpop.generate`. */
23
+ constructor(pair, jwk, thumbprint, clock = Date.now) {
24
+ this.#pair = pair;
25
+ this.#jwk = jwk;
26
+ this.#thumbprint = thumbprint;
27
+ this.#clock = clock;
28
+ }
29
+ /** Generates a new non-extractable key. */
30
+ static async generate(options = {}) {
31
+ const pair = await globalThis.crypto.subtle.generateKey(ALG, false, ['sign', 'verify']);
32
+ return Dpop.from(pair, options);
33
+ }
34
+ /** Wraps an existing key pair (its private key must be a P-256 ECDSA signing key). */
35
+ static async from(pair, options = {}) {
36
+ const exported = await globalThis.crypto.subtle.exportKey('jwk', pair.publicKey);
37
+ const jwk = { kty: 'EC', crv: 'P-256', x: exported.x, y: exported.y };
38
+ return new Dpop(pair, jwk, await thumbprint(jwk), options.clock);
39
+ }
40
+ /**
41
+ * Loads the key from `storage` (key `dpop.key`), or generates and saves one.
42
+ * Two tabs loading at the same time must end with the same key, so the
43
+ * read-or-create runs under a Web Lock when the browser has one (a key made by
44
+ * a tab that lost the race would otherwise be overwritten and its tokens
45
+ * bound to a key nobody holds any more).
46
+ */
47
+ static async load(storage, options = {}) {
48
+ const locks = options.locks === undefined ? globalThis.navigator?.locks : options.locks;
49
+ const once = async () => {
50
+ const saved = await storage.get(KEY);
51
+ if (saved?.privateKey && saved?.publicKey)
52
+ return Dpop.from(saved, options);
53
+ const made = await Dpop.generate(options);
54
+ await storage.set(KEY, made.#pair);
55
+ return made;
56
+ };
57
+ return locks ? locks.request('aduki-dpop-key', once) : once();
58
+ }
59
+ /** The key's thumbprint (RFC 7638), as stored by Aduki ID in `cnf.jkt`. */
60
+ get thumbprint() {
61
+ return this.#thumbprint;
62
+ }
63
+ /** The public JWK (kty, crv, x, y). */
64
+ get jwk() {
65
+ return { ...this.#jwk };
66
+ }
67
+ /**
68
+ * One proof for `method` on `url` (query and fragment are not signed). Pass
69
+ * `access` to bind the proof to a token with `ath`, and `nonce` when the
70
+ * server asked for one.
71
+ */
72
+ proof({ method, url, access = null, nonce = null }) {
73
+ return this.sign({ htm: method.toUpperCase(), htu: String(url).split('#')[0].split('?')[0], access, nonce });
74
+ }
75
+ /**
76
+ * One proof with `htm` and `htu` exactly as given (no upper-casing, no URL
77
+ * trimming), for proofs outside HTTP: the Chats handshake signs
78
+ * `htm = "ae09"`, `htu = "identity.response"` with the challenge as `nonce`.
79
+ */
80
+ async sign({ htm, htu, access = null, nonce = null }) {
81
+ const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: this.#jwk };
82
+ const body = {
83
+ htm,
84
+ htu,
85
+ iat: Math.floor(this.#clock() / 1000),
86
+ jti: encode(globalThis.crypto.getRandomValues(new Uint8Array(16))),
87
+ };
88
+ if (access)
89
+ body.ath = await athash(access);
90
+ if (nonce)
91
+ body.nonce = nonce;
92
+ const input = `${encodeText(JSON.stringify(header))}.${encodeText(JSON.stringify(body))}`;
93
+ // WebCrypto ECDSA returns the raw r||s form that JWS ES256 uses.
94
+ const signature = await globalThis.crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, this.#pair.privateKey, new TextEncoder().encode(input));
95
+ return `${input}.${encode(signature)}`;
96
+ }
97
+ /**
98
+ * Returns `request` with its DPoP header and scheme applied by the rules above.
99
+ * @param nonce defaults to the one remembered for the origin
100
+ */
101
+ async apply(request, nonce = this.#nonces.get(new URL(request.url).origin) ?? null) {
102
+ const sent = request.headers.get('Authorization') ?? '';
103
+ const [scheme, token] = sent.split(' ');
104
+ const headers = new Headers(request.headers);
105
+ if (sent.trim() === '') {
106
+ headers.set('DPoP', await this.proof({ method: request.method, url: request.url, nonce }));
107
+ }
108
+ else if ((scheme === 'Bearer' || scheme === 'DPoP') && token && this.bound(token)) {
109
+ headers.set('Authorization', `DPoP ${token}`);
110
+ headers.set('DPoP', await this.proof({ method: request.method, url: request.url, access: token, nonce }));
111
+ }
112
+ else {
113
+ return request;
114
+ }
115
+ return new Request(request, { headers });
116
+ }
117
+ /** True when the access token (a JWT) is bound to this key. */
118
+ bound(token) {
119
+ return claims(token).cnf?.jkt === this.#thumbprint;
120
+ }
121
+ /** A fetch that applies DPoP and retries once on a fresh `DPoP-Nonce`. */
122
+ wrap(inner = (...args) => globalThis.fetch(...args)) {
123
+ return async (input, init) => {
124
+ const request = new Request(input, init);
125
+ const spare = request.clone();
126
+ const response = await inner(await this.apply(request));
127
+ const origin = new URL(request.url).origin;
128
+ const nonce = response.headers.get('DPoP-Nonce');
129
+ if (nonce)
130
+ this.#nonces.set(origin, nonce);
131
+ if (!nonce || (response.status !== 401 && response.status !== 400))
132
+ return response;
133
+ await response.arrayBuffer().catch(() => { });
134
+ const retry = await inner(await this.apply(spare, nonce));
135
+ const next = retry.headers.get('DPoP-Nonce');
136
+ if (next)
137
+ this.#nonces.set(origin, next);
138
+ return retry;
139
+ };
140
+ }
141
+ }
142
+ /** base64url SHA-256 of the access token (`ath`) */
143
+ async function athash(token) {
144
+ return encode(await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(token)));
145
+ }
146
+ /** The RFC 7638 JWK thumbprint of an EC or OKP public key. */
147
+ export async function thumbprint(jwk) {
148
+ let canonical;
149
+ if (jwk.kty === 'EC')
150
+ canonical = { crv: jwk.crv, kty: 'EC', x: jwk.x, y: jwk.y };
151
+ else if (jwk.kty === 'OKP')
152
+ canonical = { crv: jwk.crv, kty: 'OKP', x: jwk.x };
153
+ else
154
+ throw new ProtocolError(`Unsupported key type ${jwk.kty}`);
155
+ return encode(await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(canonical))));
156
+ }
package/dist/errors.d.ts CHANGED
@@ -101,6 +101,17 @@ export declare class IdentityMismatchError extends NetworkError {
101
101
  /** `subject` or `jkt.mismatch` */
102
102
  get reason(): string;
103
103
  }
104
+ /**
105
+ * `403 access.denied` with `details.reason = "client"`: the route is not for
106
+ * a program. A workspace's service client (machine access) is refused where
107
+ * only a person may act: in Store shares, links, devices, reports, search,
108
+ * tickets and the change feed; in Space the strict and money routes. Another
109
+ * token does not help: the route needs a person signed in.
110
+ */
111
+ export declare class ClientRefusedError extends NetworkError {
112
+ /** always `client` */
113
+ get reason(): string;
114
+ }
104
115
  /**
105
116
  * A 2xx authentication answer that could not be used. `refresh` carries a
106
117
  * rotated refresh token the answer did include, so callers never lose a token
@@ -139,7 +150,8 @@ export interface Refused {
139
150
  * ProofError; `details.reason` `token.required`: TokenRequiredError;
140
151
  * `session.expired`, `session.ended`: SessionEndedError); a 429
141
152
  * RateLimitedError; a 403 with `details.reason` `subject` or `jkt.mismatch`
142
- * IdentityMismatchError; `403 auth.stepup` StepUpError;
153
+ * IdentityMismatchError; a 403 with `details.reason` `client`
154
+ * ClientRefusedError; `403 auth.stepup` StepUpError;
143
155
  * `plan.limit` and `quota.exceeded` LimitError; `sync.reset` SyncResetError;
144
156
  * a 5xx ServerError; anything else NetworkError. Every one but the 401s is a
145
157
  * NetworkError with `status`, `kind`, `details` and `retryAfter` (from the
package/dist/errors.js CHANGED
@@ -108,6 +108,19 @@ export class IdentityMismatchError extends NetworkError {
108
108
  return reasonOf(this.details);
109
109
  }
110
110
  }
111
+ /**
112
+ * `403 access.denied` with `details.reason = "client"`: the route is not for
113
+ * a program. A workspace's service client (machine access) is refused where
114
+ * only a person may act: in Store shares, links, devices, reports, search,
115
+ * tickets and the change feed; in Space the strict and money routes. Another
116
+ * token does not help: the route needs a person signed in.
117
+ */
118
+ export class ClientRefusedError extends NetworkError {
119
+ /** always `client` */
120
+ get reason() {
121
+ return reasonOf(this.details);
122
+ }
123
+ }
111
124
  /**
112
125
  * A 2xx authentication answer that could not be used. `refresh` carries a
113
126
  * rotated refresh token the answer did include, so callers never lose a token
@@ -131,7 +144,8 @@ export class StorageError extends AdukiError {
131
144
  * ProofError; `details.reason` `token.required`: TokenRequiredError;
132
145
  * `session.expired`, `session.ended`: SessionEndedError); a 429
133
146
  * RateLimitedError; a 403 with `details.reason` `subject` or `jkt.mismatch`
134
- * IdentityMismatchError; `403 auth.stepup` StepUpError;
147
+ * IdentityMismatchError; a 403 with `details.reason` `client`
148
+ * ClientRefusedError; `403 auth.stepup` StepUpError;
135
149
  * `plan.limit` and `quota.exceeded` LimitError; `sync.reset` SyncResetError;
136
150
  * a 5xx ServerError; anything else NetworkError. Every one but the 401s is a
137
151
  * NetworkError with `status`, `kind`, `details` and `retryAfter` (from the
@@ -155,6 +169,8 @@ export function refusal({ status, kind = '', message, details, retry, what }) {
155
169
  return new RateLimitedError(full, options);
156
170
  if (status === 403 && (reason === 'subject' || reason === 'jkt.mismatch'))
157
171
  return new IdentityMismatchError(full, options);
172
+ if (status === 403 && reason === 'client')
173
+ return new ClientRefusedError(full, options);
158
174
  if (kind === 'auth.stepup')
159
175
  return new StepUpError(full, options);
160
176
  if (kind === 'plan.limit' || kind === 'quota.exceeded')
@@ -0,0 +1,147 @@
1
+ /** One page of a list: `next` is the cursor of the following page, null on the last. */
2
+ export interface Page<T> {
3
+ items: T[];
4
+ next: string | null;
5
+ }
6
+ /** A success answer: the envelope's `data`, the HTTP status and the `ETag` (the row's version, without quotes). */
7
+ export interface Reply<T> {
8
+ data: T;
9
+ status: number;
10
+ etag: string | null;
11
+ }
12
+ /** A task. The money fields are present only for members who may see them. */
13
+ export interface Task {
14
+ id: string;
15
+ number: string;
16
+ title: string;
17
+ description: string | null;
18
+ priority: string;
19
+ status: string;
20
+ due: string | null;
21
+ client: {
22
+ id: string | null;
23
+ name: string | null;
24
+ } | null;
25
+ service: string | null;
26
+ team: string | null;
27
+ author: string | null;
28
+ assignees: unknown;
29
+ created: string;
30
+ updated: string;
31
+ archived: string | null;
32
+ billable?: boolean;
33
+ mode?: string | null;
34
+ rate?: number | null;
35
+ amount?: number | null;
36
+ minutes?: number;
37
+ currency?: string;
38
+ invoice?: string | null;
39
+ billed?: string | null;
40
+ [field: string]: unknown;
41
+ }
42
+ /** A check-in: work reported on a task, in minutes, waiting for review or reviewed. */
43
+ export interface Checkin {
44
+ id: string;
45
+ task: string;
46
+ number: string;
47
+ account: string | null;
48
+ body: string;
49
+ minutes: number;
50
+ proof: string | null;
51
+ status: string;
52
+ reviewer: string | null;
53
+ note: string | null;
54
+ reviewed: string | null;
55
+ created: string;
56
+ [field: string]: unknown;
57
+ }
58
+ /** A support ticket. */
59
+ export interface SupportTicket {
60
+ id: string;
61
+ number: string;
62
+ client: unknown;
63
+ contact: string | null;
64
+ assignee: string | null;
65
+ service: string | null;
66
+ node: string | null;
67
+ escalated: boolean;
68
+ task: unknown;
69
+ title: string;
70
+ status: string;
71
+ priority: string;
72
+ channel: string;
73
+ clock: string;
74
+ replied: string | null;
75
+ warned: string | null;
76
+ breached: string | null;
77
+ resolved: string | null;
78
+ closed: string | null;
79
+ created: string;
80
+ updated: string;
81
+ [field: string]: unknown;
82
+ }
83
+ /** A message of a ticket's thread; `id` is an integer (the thread is an append-only log); `internal` notes are staff only. */
84
+ export interface Message {
85
+ id: number;
86
+ side: string;
87
+ author: string | null;
88
+ internal: boolean;
89
+ body: string;
90
+ created: string;
91
+ [field: string]: unknown;
92
+ }
93
+ /** A team of the workspace (from the Aduki ID directory). */
94
+ export interface Team {
95
+ id: string;
96
+ name: string;
97
+ handle: string;
98
+ members: unknown;
99
+ }
100
+ /** A member of the workspace in the directory: name, handle and team ids. */
101
+ export interface Member {
102
+ id: string;
103
+ name: string;
104
+ handle: string;
105
+ teams: string[];
106
+ }
107
+ /** An activity entry: the workspace's for `space.admin`, your own otherwise. */
108
+ export interface Activity {
109
+ id: string;
110
+ /** who acted (hex); null for the system, or for a program of the workspace (then `kind` is `client`) */
111
+ account: string | null;
112
+ /** `client` when a program of the workspace acted (machine access); absent for a person or the system */
113
+ kind?: string;
114
+ entity: string;
115
+ action: string;
116
+ payload: unknown;
117
+ created: string;
118
+ }
119
+ /** The workspace settings (no secrets). */
120
+ export interface Settings {
121
+ currency: string;
122
+ locale: string;
123
+ zone: string;
124
+ krapin: string | null;
125
+ /** hundredths of a percent */
126
+ vat: number;
127
+ brand: unknown;
128
+ prefixes: unknown;
129
+ cutoff: number;
130
+ keep: number;
131
+ desk: string | null;
132
+ smscountries: string[];
133
+ solo: boolean;
134
+ autopayroll: boolean;
135
+ caps: unknown;
136
+ updated: string;
137
+ [field: string]: unknown;
138
+ }
139
+ /** Search ids: read each by its own route. `source` is `typesense` or `postgres` (the fallback; `stale` if behind). */
140
+ export interface Found {
141
+ items: {
142
+ collection: string;
143
+ id: string;
144
+ }[];
145
+ source: string;
146
+ stale: boolean;
147
+ }
@@ -0,0 +1,4 @@
1
+ /** The BLAKE3 hash (32 bytes) of `data`. */
2
+ export declare function blake3(data: Uint8Array): Uint8Array;
3
+ /** Lowercase hex of `bytes`. */
4
+ export declare function hex(bytes: Uint8Array): string;
@@ -0,0 +1,130 @@
1
+ // BLAKE3 (hash mode, 32-byte output) in plain TypeScript: Store names every
2
+ // chunk and every manifest by its BLAKE3, and WebCrypto has no BLAKE3. A
3
+ // straight port of the reference implementation (github.com/BLAKE3-team/BLAKE3,
4
+ // reference_impl.rs); the Store golden corpus checks it end to end.
5
+ const IV = Uint32Array.of(0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19);
6
+ const PERMUTATION = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8];
7
+ const CHUNK_START = 1;
8
+ const CHUNK_END = 2;
9
+ const PARENT = 4;
10
+ const ROOT = 8;
11
+ const BLOCK = 64;
12
+ const CHUNK = 1024;
13
+ // One state and one message schedule reused by every compression (single-threaded).
14
+ const state = new Uint32Array(16);
15
+ const words = new Uint32Array(16);
16
+ const scratch = new Uint32Array(16);
17
+ function g(a, b, c, d, x, y) {
18
+ const s = state;
19
+ s[a] = (s[a] + s[b] + x) | 0;
20
+ let t = s[d] ^ s[a];
21
+ s[d] = (t >>> 16) | (t << 16);
22
+ s[c] = (s[c] + s[d]) | 0;
23
+ t = s[b] ^ s[c];
24
+ s[b] = (t >>> 12) | (t << 20);
25
+ s[a] = (s[a] + s[b] + y) | 0;
26
+ t = s[d] ^ s[a];
27
+ s[d] = (t >>> 8) | (t << 24);
28
+ s[c] = (s[c] + s[d]) | 0;
29
+ t = s[b] ^ s[c];
30
+ s[b] = (t >>> 7) | (t << 25);
31
+ }
32
+ /** Compresses `block` (16 words) into `cv` (8 words) in place; `out` gets the first 8 output words. */
33
+ function compress(cv, block, counter, length, flags, out) {
34
+ state.set(cv.subarray(0, 8), 0);
35
+ state[8] = IV[0];
36
+ state[9] = IV[1];
37
+ state[10] = IV[2];
38
+ state[11] = IV[3];
39
+ state[12] = counter >>> 0;
40
+ state[13] = Math.floor(counter / 0x1_0000_0000) >>> 0;
41
+ state[14] = length;
42
+ state[15] = flags;
43
+ const m = words;
44
+ m.set(block);
45
+ for (let round = 0; round < 7; round++) {
46
+ g(0, 4, 8, 12, m[0], m[1]);
47
+ g(1, 5, 9, 13, m[2], m[3]);
48
+ g(2, 6, 10, 14, m[4], m[5]);
49
+ g(3, 7, 11, 15, m[6], m[7]);
50
+ g(0, 5, 10, 15, m[8], m[9]);
51
+ g(1, 6, 11, 12, m[10], m[11]);
52
+ g(2, 7, 8, 13, m[12], m[13]);
53
+ g(3, 4, 9, 14, m[14], m[15]);
54
+ if (round < 6) {
55
+ for (let i = 0; i < 16; i++)
56
+ scratch[i] = m[PERMUTATION[i]];
57
+ m.set(scratch);
58
+ }
59
+ }
60
+ for (let i = 0; i < 8; i++)
61
+ out[i] = state[i] ^ state[i + 8];
62
+ }
63
+ /** The 16 little-endian words of the 64-byte block of `data` at `offset` (zero padded). */
64
+ function load(data, offset, length, into) {
65
+ if (length === BLOCK && (data.byteOffset + offset) % 4 === 0 && littleEndian) {
66
+ into.set(new Uint32Array(data.buffer, data.byteOffset + offset, 16));
67
+ return;
68
+ }
69
+ into.fill(0);
70
+ for (let i = 0; i < length; i++)
71
+ into[i >> 2] |= data[offset + i] << ((i & 3) * 8);
72
+ }
73
+ const littleEndian = new Uint8Array(Uint32Array.of(1).buffer)[0] === 1;
74
+ /** The output of chunk `index` (`data[offset, offset + length)`, at most 1024 bytes). */
75
+ function chunk(data, offset, length, index) {
76
+ const cv = IV.slice();
77
+ const block = new Uint32Array(16);
78
+ const blocks = Math.max(1, Math.ceil(length / BLOCK));
79
+ for (let b = 0; b < blocks - 1; b++) {
80
+ load(data, offset + b * BLOCK, BLOCK, block);
81
+ compress(cv, block, index, BLOCK, b === 0 ? CHUNK_START : 0, cv);
82
+ }
83
+ const last = length - (blocks - 1) * BLOCK;
84
+ load(data, offset + (blocks - 1) * BLOCK, last, block);
85
+ return { cv, block, counter: index, length: last, flags: (blocks === 1 ? CHUNK_START : 0) | CHUNK_END };
86
+ }
87
+ function chaining(output) {
88
+ const out = new Uint32Array(8);
89
+ compress(output.cv, output.block, output.counter, output.length, output.flags, out);
90
+ return out;
91
+ }
92
+ function parent(left, right) {
93
+ const block = new Uint32Array(16);
94
+ block.set(left, 0);
95
+ block.set(right, 8);
96
+ return { cv: IV.slice(), block, counter: 0, length: BLOCK, flags: PARENT };
97
+ }
98
+ /** The BLAKE3 hash (32 bytes) of `data`. */
99
+ export function blake3(data) {
100
+ const chunks = Math.max(1, Math.ceil(data.length / CHUNK));
101
+ const stack = [];
102
+ for (let i = 0; i < chunks - 1; i++) {
103
+ let cv = chaining(chunk(data, i * CHUNK, CHUNK, i));
104
+ // Merge completed subtrees: one merge per trailing zero bit of the chunk count.
105
+ for (let total = i + 1; (total & 1) === 0; total >>= 1)
106
+ cv = chaining(parent(stack.pop(), cv));
107
+ stack.push(cv);
108
+ }
109
+ let output = chunk(data, (chunks - 1) * CHUNK, data.length - (chunks - 1) * CHUNK, chunks - 1);
110
+ while (stack.length > 0)
111
+ output = parent(stack.pop(), chaining(output));
112
+ const root = new Uint32Array(8);
113
+ compress(output.cv, output.block, 0, output.length, output.flags | ROOT, root);
114
+ const bytes = new Uint8Array(32);
115
+ for (let i = 0; i < 8; i++) {
116
+ const w = root[i];
117
+ bytes[i * 4] = w & 0xff;
118
+ bytes[i * 4 + 1] = (w >>> 8) & 0xff;
119
+ bytes[i * 4 + 2] = (w >>> 16) & 0xff;
120
+ bytes[i * 4 + 3] = (w >>> 24) & 0xff;
121
+ }
122
+ return bytes;
123
+ }
124
+ /** Lowercase hex of `bytes`. */
125
+ export function hex(bytes) {
126
+ let out = '';
127
+ for (const b of bytes)
128
+ out += b.toString(16).padStart(2, '0');
129
+ return out;
130
+ }
@@ -0,0 +1,12 @@
1
+ /** Minimum chunk size of a multi-chunk file (256 KiB). */
2
+ export declare const MIN: number;
3
+ /** Average chunk size (1 MiB). */
4
+ export declare const AVG: number;
5
+ /** Maximum chunk size, and the largest file that stays one chunk (4 MiB). */
6
+ export declare const MAX: number;
7
+ /**
8
+ * The length of the first chunk of `source` (`cut_gear` of fastcdc v2020
9
+ * with the Store parameters): at most `MAX`, all of `source` when it is at
10
+ * most `MIN` long.
11
+ */
12
+ export declare function cut(source: Uint8Array): number;
@@ -0,0 +1,127 @@
1
+ // FastCDC 2020 cut points as Store cuts them (the `chunk` crate of the Store
2
+ // repository over `fastcdc` 5.0.0 `v2020`: normalisation level 1, no seed,
3
+ // the default gear table): a file of at most 4 MiB is one chunk, a longer one
4
+ // is cut with minimum 256 KiB, average 1 MiB and maximum 4 MiB. The same
5
+ // cuts as the server's mean identical content deduplicates. The 64-bit gear
6
+ // hash runs on two 32-bit halves.
7
+ /** Minimum chunk size of a multi-chunk file (256 KiB). */
8
+ export const MIN = 256 * 1024;
9
+ /** Average chunk size (1 MiB). */
10
+ export const AVG = 1024 * 1024;
11
+ /** Maximum chunk size, and the largest file that stays one chunk (4 MiB). */
12
+ export const MAX = 4 * 1024 * 1024;
13
+ // fastcdc v2020 GEAR: 256 u64 values, 16 hex digits each.
14
+ const TABLE = '3b5d3c7d207e37dc784d68ba91123086cd52880f882e7298eacf8e4e19fdcca7c31f385dfbd1632b1d5f27001e25abe683130bde3c9ad991c4b225676e9b7649' +
15
+ 'aa329b29e08eb499b67fcbd21e577d580027baaada2acf6be3ef2d5ac73c22260890f24d6ed312b7a809e036851d7c7ef0a6fe5e0013d81b1d026304452cec14' +
16
+ '03864632648e248fcdaacf3dcd92b9b4f5e012e63c1878568862f9d3821c00b6a82f7338750f6f8a1e583dc6c1cb0b6f7a3145b69743a7f1abb20fee404807eb' +
17
+ 'b14b3cfe07b83a5db9dc27898adb9a0f3703f5e91baa62becf0bb866815f7d983d9867c41ea9dcd31be1fa65442bf22c14300da4c55631d9e698e9cbc6545c99' +
18
+ '4763107ec64e92a5c65821fc65696a2476196c064822f0b7485be841f3525e01f652bc9c85974ff5cad8352face9e3e92a6ed1dceb35e98ec6f483badc11680f' +
19
+ '3cfd8c17e9cf12f189b83c5e2ea56471ae665cfd24e392a9ec33c4e504cb89153fb9b15fc9fe7451d7fd1fd1945f219531ade0853443efd8255efc9863e1e2d2' +
20
+ '10eab6008d5642cf46f04863257ac804a52dc42a789a27d3daaadf9ce77af5656b479cd53d87febb6309e2d3f93db72fc5738ffbaa1ff9d66bd57f3f25af7968' +
21
+ '67605486d90d0a4ae14d0b9663bfbdaeb7bbd8d816eb0414def8a4f16b35a116e7932d85aaaffed608161cbae90cfd48855507beb294f08b91234ea6ffd399b2' +
22
+ 'ad70cf4b2435f302d289a97565bc2d278e558437ffca99de96d2704b7115c0400889bbcdfc660e415e0d4e67dc92128d72a9f8917063ed97438b69d409e016e3' +
23
+ 'df4fed8a5d8a439700f41dcf41d403f74814eb038e52603f9dafbacc58e2d651fe2f458e4be170af4457ec414df6a94006e62f1451123314bd1014d173ba92cc' +
24
+ 'def318e25ed577609fea0de9dfca8525459de1e76c20624baeec189617e2d666126a2c06ab5a83cbb1321532360f613265421503dbb401232d67c287ea089ab3' +
25
+ '6c93bff5a56bd6b64ffb2036cab6d98dce7b785b1be7ad4fedb42ef6189fd163dc905288703988f6365f9c1d2c691884c640583680d99bfe3cd4624c07593ec6' +
26
+ '7f1ea8d85d7c5805014842d480b571490b649bcb5a828688bcd5708ed79b18f0e987c862fbd2f2f0982731671f0cd82cbaf13e8b16d8c0638ea3109cbd951bba' +
27
+ 'd141045bfb385cad2acbc1a0af1f7d30e6444d89df03bfdfa18cc771b8188ff99834429db01c39bb214add07fe086a1f8f07c19b1f6b3ff956a297b1bf4ffe55' +
28
+ '94d558e493c54fc740bfc24c764552cb931a706f8a8520cb32229d322935bd522560d0f5dc4fefaf9dbcc48355969bb60fd81c3985c0b56ae03817e1560f2bda' +
29
+ 'c1bb4f81d892b2d5b0c4864f4e28d2d73ecc49f9d9d6c26351307e99b52ba65e8af2b688da84a752f5d72523b91b20b66d95ff1ff4634806562f21555458339a' +
30
+ 'c0ce47f889336346487823e5089b40d8e4727c7ebc6d95925a8f7277e94970bafca2f406b1c8bb505b1f8a95f1791070d304af9fc90286055440ab7fc930e748' +
31
+ '312d25fbca2ab5a110f4a4b234a4d57590301d55047e74733b6372886c61591e293402b77c444e06451f34a4d3e97dd73158d814d81bc57b034942425b9bda69' +
32
+ 'e2032ff9e532d9bb62ae066b8b2179e59545e10c2f8d71d87ff7483eb2d23fc000945fcebdc98d868764bbbe99b26ca21b1ec62284c0bfc358e0fcc4f0aa362b' +
33
+ '5f4abefa878d458dfd74ac2f9607c519a4e3fb37df8cbfa9bf697e43cac574e586f14a3f68f4cd5324a23d076f1ce522e725cd8048868cc8bf3c729eb2464362' +
34
+ 'd8f6cd57b3cc1ed86329e5242554157762aa688ad5ae1ac00a242566269bf845168b1a4753aca74bf789afefff2e7e3c6c3362093b6fccdb4ce8f50bd28c09b2' +
35
+ '006a2db95ae8aa93975b0d623c3d1a8c18605d3935338c5b5bb6f6136cad3c710f53a20701f8d8a6ab8c5ad2e7e93c6740b5ac5127acaa298c7bf63c2075895f' +
36
+ '78bd9f7e014a805cb2c9e9f4f9c8c032efd6049827eb91f32be459f482c16fbdd92ce0c5745aaa8c0aaa8fb298d965b92b37f92c6c803b158c54a5e94e0f0e78' +
37
+ '95f9b6e90c0a3032e7939faa436c7874d16bfe8f6a8a40c944982b86263fd2fae285fb39f984e583779a8df72d7619d3f2d79a8de8d5dd1ed1037354d66684e2' +
38
+ '004c82a4e668a8e531d40a7668b044e6d70578538bd02c11db45431078c5f482977121bb7f6a51ad73d5ccbd34eff8dde437a07d356e17cd47b2782043c95627' +
39
+ '9fb251413e41d49accd70b60652513d31c95b31e8a1b49b2cae73dfd1bcb4c1b34d98331b1f5b70f784e39f22338d92f18613d4a064df420f1d8dae25f0bcebe' +
40
+ '33f77c15ae855efc3c88b3b912eb109c956a2ec96bafeea51aa005b5e0ad0e875500d70527c4bb8ee36c57196421cc4413c4d286cc36ee395654a23d818b2a81' +
41
+ '77b1dc13d161abdc734f44de5f8d5eb560717e174a6c89a2d47d9649266a211e5b13a4322bb69e90f7669609f8b5fc3c21e6ac55bedcdac99b56b62b61166dea' +
42
+ 'f48f66b939797e9c35f332f9c0e6ae9acc733f6a9a878db03da161e41cc108c2b7d74ae535914d514d493b0b11d36469ce264d1dfba9741aa9d1f2dc7436dc06' +
43
+ '70738016604c2a27231d36e96e93f3d57666881197838d194a2a83090aaad40cf1e761591668b35d7363236497f730a7301080e37379dd4d502dea2971827042' +
44
+ 'c2c5eb858f32625f786afb9edfafbdffdaee0d868490b2a4617366b3268609f6ae0e35a0fe46173ed1a07de93e824f11079b8b115ea4cca893a99274558faebb' +
45
+ 'fb1e6e22e08a03b3ea635fdba3698dd0cf53659328503a5ccde3b31e6fd5d7808e3e4221d3614413ef14d0d86bf1a22ce1d830d3f16c5ddbaabd2b2a451504e1';
46
+ const GEAR_HI = new Uint32Array(256);
47
+ const GEAR_LO = new Uint32Array(256);
48
+ const LS_HI = new Uint32Array(256);
49
+ const LS_LO = new Uint32Array(256);
50
+ for (let i = 0; i < 256; i++) {
51
+ const hi = parseInt(TABLE.slice(i * 16, i * 16 + 8), 16) >>> 0;
52
+ const lo = parseInt(TABLE.slice(i * 16 + 8, i * 16 + 16), 16) >>> 0;
53
+ GEAR_HI[i] = hi;
54
+ GEAR_LO[i] = lo;
55
+ // GEAR_LS is GEAR shifted left one bit (wrapping), as in the crate.
56
+ LS_HI[i] = ((hi << 1) | (lo >>> 31)) >>> 0;
57
+ LS_LO[i] = (lo << 1) >>> 0;
58
+ }
59
+ // The masks for an average of 1 MiB at level 1: MASKS[21] (strict) and
60
+ // MASKS[19] (relaxed), and each shifted left one bit.
61
+ const S_HI = 0x0000d917;
62
+ const S_LO = 0x67537000;
63
+ const L_HI = 0x0000d917;
64
+ const L_LO = 0x07537000;
65
+ const SLS_HI = ((S_HI << 1) | (S_LO >>> 31)) >>> 0;
66
+ const SLS_LO = (S_LO << 1) >>> 0;
67
+ const LLS_HI = ((L_HI << 1) | (L_LO >>> 31)) >>> 0;
68
+ const LLS_LO = (L_LO << 1) >>> 0;
69
+ /**
70
+ * The length of the first chunk of `source` (`cut_gear` of fastcdc v2020
71
+ * with the Store parameters): at most `MAX`, all of `source` when it is at
72
+ * most `MIN` long.
73
+ */
74
+ export function cut(source) {
75
+ let remaining = source.length;
76
+ if (remaining <= MIN)
77
+ return remaining;
78
+ let center = AVG;
79
+ if (remaining > MAX)
80
+ remaining = MAX;
81
+ else if (remaining < center)
82
+ center = remaining;
83
+ const limit1 = center >>> 1;
84
+ const limit2 = remaining >>> 1;
85
+ let index = MIN >>> 1;
86
+ let hi = 0;
87
+ let lo = 0;
88
+ while (index < limit1) {
89
+ const a = index * 2;
90
+ // hash = (hash << 2) + GEAR_LS[src[a]]
91
+ let b = source[a];
92
+ hi = ((hi << 2) | (lo >>> 30)) >>> 0;
93
+ lo = (lo << 2) >>> 0;
94
+ let sum = lo + LS_LO[b];
95
+ lo = sum >>> 0;
96
+ hi = (hi + LS_HI[b] + (sum > 0xffffffff ? 1 : 0)) >>> 0;
97
+ if ((hi & SLS_HI) === 0 && (lo & SLS_LO) === 0)
98
+ return a;
99
+ // hash = hash + GEAR[src[a + 1]]
100
+ b = source[a + 1];
101
+ sum = lo + GEAR_LO[b];
102
+ lo = sum >>> 0;
103
+ hi = (hi + GEAR_HI[b] + (sum > 0xffffffff ? 1 : 0)) >>> 0;
104
+ if ((hi & S_HI) === 0 && (lo & S_LO) === 0)
105
+ return a + 1;
106
+ index++;
107
+ }
108
+ while (index < limit2) {
109
+ const a = index * 2;
110
+ let b = source[a];
111
+ hi = ((hi << 2) | (lo >>> 30)) >>> 0;
112
+ lo = (lo << 2) >>> 0;
113
+ let sum = lo + LS_LO[b];
114
+ lo = sum >>> 0;
115
+ hi = (hi + LS_HI[b] + (sum > 0xffffffff ? 1 : 0)) >>> 0;
116
+ if ((hi & LLS_HI) === 0 && (lo & LLS_LO) === 0)
117
+ return a;
118
+ b = source[a + 1];
119
+ sum = lo + GEAR_LO[b];
120
+ lo = sum >>> 0;
121
+ hi = (hi + GEAR_HI[b] + (sum > 0xffffffff ? 1 : 0)) >>> 0;
122
+ if ((hi & L_HI) === 0 && (lo & L_LO) === 0)
123
+ return a + 1;
124
+ index++;
125
+ }
126
+ return remaining;
127
+ }
@@ -0,0 +1,23 @@
1
+ /** One chunk of a manifest: where it is in the file, its size and its BLAKE3 (64 hex digits). */
2
+ export interface Chunk {
3
+ offset: number;
4
+ size: number;
5
+ hash: string;
6
+ }
7
+ /** A file's chunk list, its size and its manifest hash. */
8
+ export interface Manifest {
9
+ size: number;
10
+ hash: string;
11
+ chunks: Chunk[];
12
+ }
13
+ /** Content the SDK can upload: a Blob (a File too), an ArrayBuffer or bytes. */
14
+ export type Content = Blob | ArrayBuffer | Uint8Array;
15
+ /** `content` as a Blob (bytes are copied once). */
16
+ export declare function blob(content: Content): Blob;
17
+ /** The manifest of `content`; `progress` is called with the bytes read so far. */
18
+ export declare function manifest(content: Content, progress?: (read: number) => void): Promise<Manifest>;
19
+ /** The manifest hash of `chunks`. */
20
+ export declare function seal(chunks: readonly {
21
+ hash: string;
22
+ size: number;
23
+ }[]): string;