@atlastodo/client-core 0.1.13 → 0.1.14-rc.1

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/api.d.ts CHANGED
@@ -1,31 +1,22 @@
1
1
  /** Typed HTTP client: auth, key distribution, membership, reports, admin and `/sync/*`. A 401 rotates the refresh token once. */
2
2
  import type { AdminAuditEntry, AdminInviteView, AdminSettingsView, AdminUserView, AuthResponse, AuthUser, BugReportPayload, BugReportSummary, BugReportView, InviteView, MemberRole, MemberView, MissingProjectKey, PendingKeyRotation, Operation, ProjectKeysResponse, RecoverAccountPayload, RecoveryKeysResponse, RegisterRecoveryKeyPayload, ReplaceRecoveryKeyPayload, SaltResponse, SessionView, ApiTokenView, ApiTokenSessionResponse, CreateApiTokenPayload, UserPublicKeyResponse, SigningKeyResponse, ChangePasswordPayload, AppKeyRow, MigrateRootKeyPayload, PutRecoveryRootKeyPayload, RootKeyResponse, AccountProof, PasskeyAssertionResponse, PasskeyCreationOptions, PasskeyReauthResponse, PasskeyRegistrationPayload, PasskeyRequestOptions, PasskeyView } from "./types.js";
3
- import { Keyring, type EncryptedPayload, type PasswordKdf, type SealedKey, type WrappedProjectKey } from "./crypto/index.js";
3
+ import { Keyring, type EncryptedPayload, type PasswordKdf, type SealedInvitePreview, type SealedKey, type WrappedProjectKey } from "./crypto/index.js";
4
4
  import { type ServerDiscovery } from "./discovery.js";
5
5
  import { type Repair, type ScopeStore } from "./scope.js";
6
- export type WireOp = {
7
- id: string;
8
- entity: Operation["entity"];
9
- entity_id: string;
10
- ts: {
11
- wall_ms: number;
12
- counter: number;
13
- node: string;
14
- };
15
- } & ({
16
- op: "set";
17
- field: string;
18
- value: unknown;
19
- } | {
20
- op: "delete";
21
- });
6
+ import { type WireOp } from "./wire.js";
7
+ import type { LegacyKeysPage } from "./legacy.js";
8
+ export type { WireOp } from "./wire.js";
22
9
  /** Sync without an unlocked keyring would push plaintext or store ciphertext as content. Transient. */
23
10
  export declare class E2eeLockedError extends Error {
24
11
  constructor();
25
12
  }
26
13
  /** Explicit scope: `projectId`'s key (throws when not held) or the DEK when null; never falls back. */
27
14
  export declare function toEncryptedWire(op: Operation, keyring?: Keyring | null, projectId?: string | null): WireOp;
28
- /** `projectId` null is personal, undefined unknown (any held key may open). Unopened values stay envelopes. */
15
+ /**
16
+ * One wire op, decoded on its own: a current one opened with the keys of its scope (still sealed
17
+ * without them), a legacy one by the protocol 6 rules, where `projectId` null is personal and
18
+ * undefined unknown (any held key may open). Throws for an op the decoder drops.
19
+ */
29
20
  export declare function fromEncryptedWire(w: WireOp, keyring?: Keyring | null, projectId?: string | null): Operation;
30
21
  type WireReport = {
31
22
  id: string;
@@ -148,6 +139,8 @@ export declare class ApiClient {
148
139
  /** Legacy migration done: plaintext in encrypted fields and v1 values are then dropped. Local, so the server cannot roll it back. */
149
140
  setLegacyMigrated(done: boolean): void;
150
141
  isLegacyMigrated(): boolean;
142
+ /** The real kind of an entity the attached store holds by this id. */
143
+ private kindOfEntity;
151
144
  private isCanonical;
152
145
  private unlockedKeyring;
153
146
  setTokens(token: string | undefined, refreshToken?: string): void;
@@ -385,10 +378,24 @@ export declare class ApiClient {
385
378
  from?: number;
386
379
  repairs: Repair[];
387
380
  };
381
+ /** The protocol 6 entities this partition still holds, a page at a time (`migrateLegacy`). */
382
+ legacyKeys(next?: string): Promise<LegacyKeysPage>;
383
+ /** Drop re-encoded protocol 6 entities from this partition; answers how many it still held. */
384
+ dropLegacy(keys: {
385
+ entity: string;
386
+ entity_id: string;
387
+ }[]): Promise<number>;
388
+ /**
389
+ * Whether this account may re-write every field of the entity: its own activity only (the server
390
+ * names the pusher as the actor), a shared project only as its owner (archive and delete are
391
+ * owner-only), a shared project's item only as an active member.
392
+ */
393
+ mayReencode(kind: Operation["entity"], entityId: string): boolean;
388
394
  /** WebSockets cannot set headers and proxies may log the URL, so it carries a single-use 30 s ticket, never the access token. */
389
395
  syncWsUrl(since: number): Promise<string | null>;
390
396
  listMembers(projectId: string): Promise<MemberView[]>;
391
- inviteMember(projectId: string, email: string, role: MemberRole): Promise<MemberView>;
397
+ /** `preview`: what the invitee is shown before accepting (`sealInvitePreview`). */
398
+ inviteMember(projectId: string, email: string, role: MemberRole, preview?: SealedInvitePreview): Promise<MemberView>;
392
399
  acceptInvite(projectId: string): Promise<void>;
393
400
  declineInvite(projectId: string): Promise<void>;
394
401
  updateMemberRole(projectId: string, userId: string, role: MemberRole): Promise<MemberView>;
@@ -396,4 +403,3 @@ export declare class ApiClient {
396
403
  claimProjectOwnership(projectId: string): Promise<MemberView>;
397
404
  listInvites(): Promise<InviteView[]>;
398
405
  }
399
- export {};
@@ -48,16 +48,16 @@ export declare function unwrapAttachmentKey(wrapped: WrappedAttachmentKey, key:
48
48
  */
49
49
  export declare function isWrappedAttachmentKey(v: unknown): v is WrappedAttachmentKey;
50
50
  /**
51
- * Whether a field value is still in encrypted wire form (`{ __enc: 2, kid, iv, ct }` or older
52
- * `__enc: 1`). A field no key here opens is stored as this object, so its presence means
53
- * "unreadable here", not "empty".
51
+ * Whether a field value is still in encrypted wire form (`{ __enc: 3, kid, iv, ct }`, or the older
52
+ * `__enc: 2` and `__enc: 1`). A field no key here opens is stored as this object, so its presence
53
+ * means "unreadable here", not "empty".
54
54
  */
55
55
  export declare function isEncryptedEnvelope(v: unknown): v is {
56
56
  __enc: 1;
57
57
  iv: string;
58
58
  ct: string;
59
59
  } | {
60
- __enc: 2;
60
+ __enc: 2 | 3;
61
61
  kid: string;
62
62
  iv: string;
63
63
  ct: string;
@@ -1,9 +1,12 @@
1
1
  /**
2
- * Field-value encryption: the `{ __enc: 2, kid, iv, ct }` envelope. The AES-GCM associated data binds
3
- * a value to its envelope version, entity kind and id, field name, key scope and key id, so
4
- * ciphertext the server copies elsewhere fails to open instead of being read or re-encrypted there.
5
- * The JSON plaintext is padded to a size bucket so length does not reveal `true` from `false` or a
6
- * short title from a long one. Version 1 (unpadded, no associated data) is read, never written.
2
+ * Field-value encryption. Version 3 (`sealField`, sync protocol 7) is what clients write: the
3
+ * plaintext holds the real kind and field name too, and the wire carries an opaque tag
4
+ * (`fieldTag`) instead of the field name. Version 2 (`{ __enc: 2, kid, iv, ct }`) is read for rows
5
+ * written before protocol 7. In both, the AES-GCM associated data binds a value to its envelope
6
+ * version, wire kind and entity id, wire field, key scope and key id, so ciphertext the server
7
+ * copies elsewhere fails to open instead of being read or re-encrypted there. Both pad the JSON
8
+ * plaintext to a size bucket so length does not reveal `true` from `false` or a short title from a
9
+ * long one. Version 1 (unpadded, no associated data) is read, never written.
7
10
  */
8
11
  export declare const FIELD_ENVELOPE_VERSION = 2;
9
12
  export type KeyScope = {
@@ -26,12 +29,12 @@ export interface FieldEnvelopeV2 {
26
29
  export declare function sameKeyScope(a: KeyScope, b: KeyScope): boolean;
27
30
  export declare function keyScopeLabel(scope: KeyScope): string;
28
31
  /**
29
- * The padded size for `n` bytes of plaintext plus the one-byte pad marker: at least 32, then the
30
- * next power of two up to 1 KiB, then the next multiple of 1 KiB.
32
+ * The padded size for `n` bytes of plaintext plus the one-byte pad marker: at least `min` (32 for
33
+ * version 2), then the next power of two up to 1 KiB, then the next multiple of 1 KiB.
31
34
  */
32
- export declare function paddedLength(n: number): number;
35
+ export declare function paddedLength(n: number, min?: number): number;
33
36
  /** Append 0x80 and zeros up to the bucket size (ISO/IEC 7816-4 padding). */
34
- export declare function padPlaintext(data: Uint8Array): Uint8Array;
37
+ export declare function padPlaintext(data: Uint8Array, min?: number): Uint8Array;
35
38
  export declare function unpadPlaintext(data: Uint8Array): Uint8Array;
36
39
  export declare function encryptField(key: Uint8Array, kid: string, scope: KeyScope, at: FieldLocation, value: unknown): FieldEnvelopeV2;
37
40
  /**
@@ -39,4 +42,44 @@ export declare function encryptField(key: Uint8Array, kid: string, scope: KeySco
39
42
  * when any of that is untrue: another key, another scope, or a value copied from elsewhere.
40
43
  */
41
44
  export declare function decryptField(key: Uint8Array, scope: KeyScope, at: FieldLocation, envelope: FieldEnvelopeV2): unknown;
45
+ /** Sync protocol 7's envelope (docs/decisions/0006). */
46
+ export declare const FIELD_ENVELOPE_V3 = 3;
47
+ /**
48
+ * A v3 plaintext carries the kind and field name too, so its smallest bucket is 64 bytes: a small
49
+ * value of a long-named field must not fall in another bucket than one of a short-named field.
50
+ */
51
+ export declare const FIELD_V3_MIN_PADDING = 64;
52
+ export interface FieldEnvelopeV3 {
53
+ __enc: 3;
54
+ kid: string;
55
+ iv: string;
56
+ ct: string;
57
+ }
58
+ /** Where a v3 envelope sits on the wire: the wire kind and wire field (a tag or a public name). */
59
+ export interface WireLocation {
60
+ entity: string;
61
+ entityId: string;
62
+ field: string;
63
+ }
64
+ /** What a v3 envelope holds. */
65
+ export interface SealedField {
66
+ kind: string;
67
+ field: string;
68
+ value: unknown;
69
+ }
70
+ export declare function sealField(key: Uint8Array, kid: string, scope: KeyScope, at: WireLocation, sealed: SealedField): FieldEnvelopeV3;
71
+ /**
72
+ * Open a v3 envelope that claims to sit at `at` under `key` of `scope`. Throws when any of that is
73
+ * untrue, or when the plaintext is not a `[kind, field, value]` triple.
74
+ */
75
+ export declare function openSealedField(key: Uint8Array, scope: KeyScope, at: WireLocation, envelope: FieldEnvelopeV3): SealedField;
76
+ export declare function isFieldEnvelopeV3(v: unknown): v is FieldEnvelopeV3;
77
+ /** The key a scope's field tags are made with: HKDF-SHA256 of the encrypting key. */
78
+ export declare function fieldTagKey(key: Uint8Array): Uint8Array;
79
+ /**
80
+ * The opaque wire name of `field` of `kind` entity `entityId`: 32 lowercase hex digits. The entity
81
+ * id is part of it, so the same field of two entities has unrelated tags.
82
+ */
83
+ export declare function fieldTag(tagKey: Uint8Array, entityId: string, kind: string, field: string): string;
84
+ export declare function isFieldTag(name: string): boolean;
42
85
  export declare function isFieldEnvelopeV2(v: unknown): v is FieldEnvelopeV2;
@@ -11,3 +11,4 @@ export * from "./blob.js";
11
11
  export * from "./apiToken.js";
12
12
  export * from "./rootKey.js";
13
13
  export * from "./passkey.js";
14
+ export * from "./invitePreview.js";
@@ -0,0 +1,23 @@
1
+ /**
2
+ * What an invitee is shown before accepting (sync protocol 7): the project's name, icon, color and
3
+ * kind, which the server cannot read any more, encrypted by the inviter under the project key the
4
+ * invitee receives sealed with the invite. Bound to the project and the key id, and padded like a
5
+ * field value, so the server can neither move it to another project nor learn the name's length.
6
+ */
7
+ export interface InvitePreview {
8
+ name?: unknown;
9
+ icon?: unknown;
10
+ color?: unknown;
11
+ kind?: unknown;
12
+ }
13
+ export interface SealedInvitePreview {
14
+ v: 1;
15
+ kid: string;
16
+ iv: string;
17
+ ct: string;
18
+ }
19
+ /** Under project key `pek` (id `kid`) of `projectId`. */
20
+ export declare function sealInvitePreview(pek: Uint8Array, kid: string, projectId: string, preview: InvitePreview): SealedInvitePreview;
21
+ /** Throws unless `pek` sealed it for `projectId`. */
22
+ export declare function openInvitePreview(pek: Uint8Array, projectId: string, sealed: SealedInvitePreview): InvitePreview;
23
+ export declare function isSealedInvitePreview(v: unknown): v is SealedInvitePreview;
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Which field values leave the device in plaintext. Everything else, including fields nothing
3
- * declares yet, is end-to-end encrypted, so a new field cannot leak because a list was forgotten.
2
+ * What of an entity leaves the device readable (sync protocol 7, docs/decisions/0006): the wire
3
+ * kind it travels as instead of its real kind, the few field names that travel as they are instead
4
+ * of an opaque tag (`public_fields`), and which of those carry a plaintext value
5
+ * (`plaintext_fields`). Everything else, including fields nothing declares yet, travels under a
6
+ * tag in an encrypted value, so a new field cannot leak because a list was forgotten.
4
7
  *
5
- * The table is the `plaintext_fields` of each kind in the bundled app manifests
6
- * (`manifests/<app>.json`), which the server compiles in too and checks every field value it
7
- * reads against.
8
+ * The tables are the bundled app manifests (`manifests/<app>.json`), which the server compiles in
9
+ * too. `legacy_plaintext_fields` is what protocol 6 sent unencrypted, which reading the rows
10
+ * written before protocol 7 needs.
8
11
  */
9
12
  import type { EntityKind } from "./types.js";
10
13
  /**
@@ -22,13 +25,18 @@ export interface TokenPolicy {
22
25
  exclusive: boolean;
23
26
  }
24
27
  /** A bundled app manifest: its id, version, the entity kinds it syncs and its token policy. */
28
+ export interface KindManifest {
29
+ /** The kind the server sees. */
30
+ wire: string;
31
+ public_fields?: readonly string[];
32
+ plaintext_fields?: readonly string[] | "all";
33
+ legacy_plaintext_fields?: readonly string[];
34
+ }
25
35
  export interface AppManifest {
26
36
  app: string;
27
37
  name: string;
28
38
  version: number;
29
- kinds: Record<string, {
30
- plaintext_fields: readonly string[] | "all";
31
- }>;
39
+ kinds: Record<string, KindManifest>;
32
40
  api_tokens?: Partial<TokenPolicy>;
33
41
  }
34
42
  /**
@@ -47,10 +55,29 @@ export declare const BUNDLED_MANIFESTS: readonly AppManifest[];
47
55
  export declare function tokenPolicyFor(apps: readonly string[]): TokenPolicy;
48
56
  /** The bundled app owning the kind with this wire name, if any. */
49
57
  export declare function appOfKind(kind: string): AppManifest | undefined;
58
+ /** Per real kind, the fields whose value travels unencrypted. */
50
59
  export declare const PLAINTEXT_FIELDS: Readonly<Record<EntityKind, readonly string[] | "all">>;
51
60
  /**
52
- * Whether a value of `entity`.`field` is end-to-end encrypted, for any wire kind: a kind no
53
- * bundled manifest names encrypts everything.
61
+ * The app owning wire name `name`: a wire kind (`todo.sealed`), or a real kind name, which rows
62
+ * written before protocol 7 carry.
54
63
  */
64
+ export declare function appOfWire(name: string): AppManifest | undefined;
65
+ /** The wire kind `kind` travels as; null for a kind no bundled manifest names. */
66
+ export declare function wireKindOf(kind: string): string | null;
67
+ /** Whether `field` of `kind` goes on the wire under its name rather than an opaque tag. */
68
+ export declare function isPublicField(kind: string, field: string): boolean;
69
+ /** Whether a value of `kind`.`field` leaves the device encrypted (protocol 7). */
55
70
  export declare function shouldEncryptKindField(entity: string, field: string): boolean;
56
71
  export declare function shouldEncryptField(entity: EntityKind, field: string): boolean;
72
+ /** Whether protocol 6 encrypted a value of `kind`.`field`: how a legacy row is read. */
73
+ export declare function legacyEncrypted(kind: string, field: string): boolean;
74
+ /** Whether `name` is the wire kind of rows written before protocol 7: a real kind that now
75
+ * travels as another. */
76
+ export declare function isLegacyWireKind(name: string): boolean;
77
+ /** The real kinds that travel as wire kind `wire`. */
78
+ export declare function kindsOfWire(wire: string): EntityKind[];
79
+ /**
80
+ * The real kind a public field names on wire kind `wire` (`blob_sha` on `todo.sealed` is an
81
+ * attachment's), or the wire kind's only real kind; null when neither tells.
82
+ */
83
+ export declare function realKindOf(wire: string, publicField?: string): EntityKind | null;
package/dist/index.d.ts CHANGED
@@ -20,6 +20,8 @@ export * from "./attachments.js";
20
20
  export * from "./indexeddb.js";
21
21
  export * from "./expo-sqlite.js";
22
22
  export * from "./sync-client.js";
23
+ export * from "./wire.js";
24
+ export * from "./legacy.js";
23
25
  export * from "./realtime.js";
24
26
  export * from "./session.js";
25
27
  export * from "./password.js";