id-kit / types / index.d.ts
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
/**
 * Hand-authored declarations for the "." export of ardegazu-id-kit —
 * the compiled dist/index.js (ClojureScript). Mirrors the v1 TypeScript
 * API surface exactly; wire and storage shapes are unchanged.
 */

// ---- crypto ----------------------------------------------------------------

/** Bytes always backed by a plain ArrayBuffer (what WebCrypto's types demand). */
export type Bytes = Uint8Array<ArrayBuffer>;

export declare function utf8(s: string): Bytes;
export declare function randomBytes(n: number): Bytes;
export declare function toB64(bytes: Bytes): string;
export declare function fromB64(s: string): Bytes;
export declare function toB64url(bytes: Bytes): string;
export declare function fromB64url(s: string): Bytes;

// ---- identity --------------------------------------------------------------

export interface Fingerprint {
  emoji: string; // 4 emoji ≈ casual comparison
  hex: string; // full SHA-256 of the public key, for careful comparison
}

export declare class Identity {
  readonly publicKeyB64: string;
  get fingerprint(): Fingerprint;
  /** Load from a seed string (43-char base64url). Throws on malformed seeds. */
  static fromSeed(seedB64url: string): Promise<Identity>;
  static newSeed(): string;
  /** Sign the session binding: proves this room-session peerId belongs to this identity. */
  assert(appSalt: string, roomId: string, peerId: string): Promise<string>;
  /** Raw Ed25519 signature over arbitrary bytes (OrbitDB identity provider). */
  signRaw(data: Bytes): Promise<Bytes>;
  private constructor();
}

/** Verify a raw Ed25519 signature against a base64url public key. */
export declare function verifyRaw(publicKeyB64: string, sigB64url: string, data: Bytes): Promise<boolean>;

/** Verify a peer's binding assertion; returns the fingerprint or null. */
export declare function verifyAssertion(
  appSalt: string,
  roomId: string,
  peerId: string,
  publicKeyB64: string,
  sigB64: string,
): Promise<Fingerprint | null>;

export declare function fingerprintOf(publicKeyB64: string): Promise<Fingerprint>;

export declare function isEd25519Supported(): Promise<boolean>;

// ---- profile ---------------------------------------------------------------

export interface Profile {
  name: string;
  hue: number | null; // 0..359 chosen accent; null = hueOfPub(idPub)
  glyph: string | null; // chosen emoji; null = first fingerprint emoji
  lang: string | null; // ISO 639-1 UI language; null = follow the browser
}

export declare const EMPTY_PROFILE: Profile;
export declare const MAX_NAME_LEN: number;

/** Same hash the suite always used for colors — over the identity pub, not the peerId. */
export declare function hueOfPub(pubB64: string): number;

/** Effective hue for an identity: chosen hue if set, else derived. */
export declare function effectiveHue(pubB64: string, profile?: Pick<Profile, "hue"> | null): number;

/** The suite's standard chat-style color for an identity. */
export declare function colorOfPub(pubB64: string, profile?: Pick<Profile, "hue"> | null): string;

/** Effective glyph: chosen emoji if set, else the first fingerprint emoji. */
export declare function defaultGlyph(pubB64: string): Promise<string>;

/**
 * Clamp untrusted profile data (bridge messages, peer hellos) to a safe shape.
 * Never throws; unusable fields collapse to their null/empty defaults.
 */
export declare function clampProfile(p: unknown): Profile;

// ---- protocol --------------------------------------------------------------

/** Where the bridge page lives, relative to the apex origin. */
export declare const BRIDGE_PATH: string;
export declare const DEFAULT_BRIDGE_URL: string;

/** The one localStorage key on the apex origin. */
export declare const BRIDGE_STORE_KEY: string;

/** 32 random bytes, base64url — the whole identity. */
export declare const SEED_RE: RegExp;

/** Origins allowed to talk to the bridge host. */
export declare const APP_ORIGIN_RE: RegExp;
export declare const DEV_ORIGINS: readonly string[];

export declare function isAllowedAppOrigin(origin: string): boolean;

/** Shape of an `apps` section key: one lowercase label, 1..32 chars. */
export declare const APP_KEY_RE: RegExp;

/**
 * Keys the suite hands out by construction, never by subdomain label:
 * `"home"` (the apex hub) and `"dev"` (the localhost dev origins). A
 * subdomain spelling either of them derives `null`.
 */
export declare const RESERVED_APP_KEYS: readonly string[];

/**
 * The `apps` key an origin owns — the subdomain label for
 * `https://<label>.ardegazu.ro`, `"home"` for the apex, `"dev"` for a dev
 * origin, `null` for anything else. A pure function of the origin: an app can
 * only ever write the section its own origin names.
 *
 * A key implies an allowed origin, but NOT the reverse: an allowed origin
 * whose label is longer than 32 chars, or is a reserved label, derives `null`
 * and simply has no section.
 */
export declare function appKeyOfOrigin(origin: unknown): string | null;

/** One app's section of the suite record. `ts` is stamped by the host. */
export interface AppEntry {
  state: unknown;
  ts: number;
}

/** The suite identity record, as stored on the apex origin. */
export interface IdRecord {
  v: 1;
  seed: string; // 43-char base64url — THE identity
  name: string;
  hue: number | null;
  glyph: string | null;
  /** ISO 639-1 UI language; null = follow the browser. Absent in old records. */
  lang: string | null;
  /** Opaque social blob (friends/blocked), owned by ardegazu-social-kit. */
  soc: unknown | null;
  /** Per-app sections, keyed by appKeyOfOrigin. Every app reads all of them;
   *  an app may only write its own, and only through a `t:"app"` message —
   *  `apps` on a `t:"put"` payload is ignored outright. Absent in pre-v2.1
   *  records. */
  apps: Record<string, AppEntry> | null;
  createdAt: number;
  updatedAt: number;
}

export type ProfilePatch = Partial<Pick<Profile, "name" | "hue" | "glyph" | "lang">>;

/**
 * Why an `app` write was refused. `"cas"` is worth retrying (someone else
 * wrote first); `"key"` and `"size"` never are; `"full"` means the sections'
 * collective budget is spent, so the caller must shrink or drop its own.
 */
export type AppWriteRefusal = "cas" | "key" | "size" | "full";

/** app → bridge */
export type BridgeRequest =
  | { t: "get"; v: 1; reqId: number }
  /** `rec.apps` is IGNORED — sections are only ever written by `t:"app"`. */
  | { t: "put"; v: 1; reqId: number; rec: IdRecord; expect: string | null }
  | { t: "profile"; v: 1; reqId: number; expect: string; patch: ProfilePatch }
  | { t: "soc"; v: 1; reqId: number; expect: string; soc: unknown }
  /** Write THIS origin's `apps` section; state null deletes it. */
  | { t: "app"; v: 1; reqId: number; expect: string; state: unknown }
  | { t: "clear"; v: 1; reqId: number; expect: string };

/** bridge → app */
export type BridgeReply =
  | { t: "id-ready"; v: 1 }
  | { t: "state"; v: 1; reqId?: number; rec: IdRecord | null; ephemeral?: true }
  /** `reason` is additive (only the `app` arm sets it): a client that does not
   *  know the field behaves exactly as before. */
  | { t: "conflict"; v: 1; reqId: number; rec: IdRecord | null; reason?: AppWriteRefusal };

export declare function isBridgeReply(x: unknown): x is BridgeReply;
export declare function isBridgeRequest(x: unknown): x is BridgeRequest;

// ---- bridge host -----------------------------------------------------------

/**
 * Hard cap on the serialized record (`soc` and `apps` are the open-ended
 * fields).
 *
 * UNITS — one rule, shared suite-wide: every `*_BYTES` constant here counts
 * **UTF-16 code units of the JSON serialization**, i.e.
 * `JSON.stringify(x).length`. Never UTF-8 bytes. The two agree for ASCII and
 * diverge above U+007F (a Romanian `ș` is one code unit and two bytes), so the
 * same value measured the other way can be twice the number below. The sync
 * layer that carries these records between a user's devices measures them the
 * same way — a kit that disagreed would accept locally what the other end
 * silently drops. The values, so drift is visible:
 *
 *     MAX_RECORD_BYTES      131072 code units
 *     MAX_APP_STATE_BYTES    16384 code units
 *     RESERVED_CORE_BYTES    32768 code units
 *     MAX_APPS_BYTES         98304 code units
 */
export declare const MAX_RECORD_BYTES: number;

/** Per-entry cap on one `apps` section's serialized state — 16384 UTF-16 code
 *  units. A section is directory metadata an app publishes for the suite to
 *  act on, not history. */
export declare const MAX_APP_STATE_BYTES: number;

/** Headroom inside MAX_RECORD_BYTES that the `apps` map may never take, so
 *  `soc` and the profile can always be written — 32768 UTF-16 code units. */
export declare const RESERVED_CORE_BYTES: number;

/** What the whole `apps` map may serialize to — 98304 UTF-16 code units,
 *  i.e. MAX_RECORD_BYTES - RESERVED_CORE_BYTES. */
export declare const MAX_APPS_BYTES: number;

/** Validate untrusted data into an IdRecord, or null. Never throws. */
export declare function sanitizeRecord(x: unknown): IdRecord | null;

/** Read the record straight from apex-origin storage (hub + host use). */
export declare function readBridgeRecord(): { rec: IdRecord | null; ephemeral: boolean };

/** Write (or clear) the record on the apex origin. False = storage unavailable. */
export declare function writeBridgeRecord(rec: IdRecord | null): boolean;

export declare function initBridgeHost(): void;

// ---- bridge client ---------------------------------------------------------

export interface IdBridgeOptions {
  /** Storage namespace of the app, e.g. "chat-ardegazu-ro-v2". */
  ns: string;
  /** Bridge page URL; override in dev. */
  bridgeUrl?: string;
  /** Per-operation bridge timeout. */
  timeoutMs?: number;
}

/**
 * Outcome of `putAppStateDetailed`. Beyond the host's own refusals
 * (`AppWriteRefusal`): `"noseed"` — this app has no identity yet, so there is
 * nothing to attach a section to; `"offline"` — the bridge never answered;
 * `"conflict"` — a host too old to say why.
 */
export interface AppStateResult {
  ok: boolean;
  reason: AppWriteRefusal | "noseed" | "offline" | "conflict" | null;
}

export interface BootResult {
  seed: string | null;
  /** null when the seed is absent or Ed25519 is unsupported — apps keep
   *  working identity-less exactly as they always have. */
  identity: Identity | null;
  profile: Profile;
  source: "mirror" | "bridge" | "fresh" | "none";
  /** The bridge holds a DIFFERENT identity than the mirror. */
  conflict: IdRecord | null;
  /** The bridge answered this session. */
  bridged: boolean;
}

export declare class IdBridge {
  constructor(opts: IdBridgeOptions);
  mirrorSeed(): string | null;
  mirrorProfile(): Profile & { ts: number };
  onChange(cb: (rec: IdRecord | null) => void): () => void;
  /** Last record the bridge reported this session (null = none/unreached). */
  lastRecord(): IdRecord | null;
  boot(): Promise<BootResult>;
  /** Update profile fields: mirror immediately, bridge best-effort. */
  putProfile(patch: ProfilePatch): Promise<void>;
  /** Replace the suite social blob (owned by ardegazu-social-kit). */
  putSoc(soc: unknown): Promise<boolean>;
  /** Replace THIS app's section of the suite record. Online-only (no mirror):
   *  false when the bridge is unreachable or the write was refused. null
   *  deletes it. */
  putAppState(state: unknown): Promise<boolean>;
  /** The same write, with WHY it failed — so a caller can retry a `"cas"` and
   *  never retry a `"size"`. `reason` is null on success. */
  putAppStateDetailed(state: unknown): Promise<AppStateResult>;
  /** The `apps` key this origin owns, or null off-suite. */
  appKey(): string | null;
  /** Any app's section state, from the last record the bridge reported.
   *  Read every section with `lastRecord()!.apps` / `onChange`. */
  appStateOf(key: string): unknown;
  /** Conflict chooser: make the SUITE identity this app's identity too.
   *  Backs up the old seed to `<ns>:id-prev`. Caller reloads the app. */
  adoptBridgeSeed(bridgeRec: IdRecord): void;
  /** Conflict chooser: keep the app-local identity; stop asking about this
   *  particular suite seed (a future different suite seed asks again). */
  keepLocalSeed(bridgeRec: IdRecord): void;
  /** Explicitly make `seed` the suite identity (identity-sheet import flow).
   *  force=false claims only an empty bridge; force=true overwrites. */
  publishSeed(seed: string, opts?: { force?: boolean }): Promise<boolean>;
  destroy(): void;
}

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/id-kit.git