@glance-apps/sync 1.6.1 → 1.10.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.
- package/CHANGELOG.md +343 -0
- package/package.json +1 -1
- package/src/dbEngine.js +450 -11
- package/src/index.js +15 -2
- package/src/vaultClient.js +208 -9
- package/src/vaultConnect.js +391 -0
- package/types/index.d.ts +211 -1
package/types/index.d.ts
CHANGED
|
@@ -27,7 +27,20 @@ export type SyncErrorCode =
|
|
|
27
27
|
| 'VERIFIER_UNSUPPORTED'
|
|
28
28
|
// DB transport: a row-scoped call (incl. the key verifier) was made before the
|
|
29
29
|
// accountId was populated — retryable once the account id is available.
|
|
30
|
-
| 'ACCOUNT_ID_REQUIRED'
|
|
30
|
+
| 'ACCOUNT_ID_REQUIRED'
|
|
31
|
+
// DB transport, per-account auth: the server rejected this device's
|
|
32
|
+
// credential (401 "invalid credential"). Surfaced with isHardStop === true;
|
|
33
|
+
// the engine persists a halt and stops retrying. Recovery (re-enrollment)
|
|
34
|
+
// is Phase 2.2 — nothing in this version clears the halt.
|
|
35
|
+
| 'CREDENTIAL_INVALID'
|
|
36
|
+
// DB transport: the server rejected a write because the account is over a
|
|
37
|
+
// configured quota (413 storage-shaped / 429 volume-shaped, Phase 3.2).
|
|
38
|
+
// ALWAYS isHardStop === false — a quota condition clears when the operator
|
|
39
|
+
// raises the limit or reclaim runs, with NO client action, so the engine
|
|
40
|
+
// never halts on it. The parsed descriptor rides on the engine's
|
|
41
|
+
// getQuotaState(); the engine suppresses further writes for a bounded,
|
|
42
|
+
// self-resuming window while the pull keeps running.
|
|
43
|
+
| 'QUOTA_EXCEEDED';
|
|
31
44
|
|
|
32
45
|
export type SyncStatus = 'idle' | 'uploading' | 'downloading' | 'success' | 'error';
|
|
33
46
|
|
|
@@ -421,10 +434,53 @@ export interface VaultClient {
|
|
|
421
434
|
|
|
422
435
|
export function createVaultClient(config: {
|
|
423
436
|
vaultUrl: string;
|
|
437
|
+
/** Shared device token, or the device's enrolled credential in per-account mode. */
|
|
424
438
|
vaultToken: string;
|
|
425
439
|
fetchImpl?: typeof fetch;
|
|
426
440
|
}): VaultClient;
|
|
427
441
|
|
|
442
|
+
// Per-account auth (vault Phase 1.4b).
|
|
443
|
+
|
|
444
|
+
/** Which trust model a GLANCEvault server runs. Discoverable via fetchVaultHealth. */
|
|
445
|
+
export type VaultAuthMode = 'shared' | 'per-account';
|
|
446
|
+
|
|
447
|
+
export interface VaultHealth {
|
|
448
|
+
status: string;
|
|
449
|
+
version: string;
|
|
450
|
+
schemaVersion: number;
|
|
451
|
+
/** Normalized to 'shared' when the server predates the field. */
|
|
452
|
+
authMode: VaultAuthMode;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Unauthenticated GET /healthz. Safe to call before any token exists. */
|
|
456
|
+
export function fetchVaultHealth(config: {
|
|
457
|
+
vaultUrl: string;
|
|
458
|
+
fetchImpl?: typeof fetch;
|
|
459
|
+
}): Promise<VaultHealth>;
|
|
460
|
+
|
|
461
|
+
export interface VaultEnrollment {
|
|
462
|
+
credentialId: string;
|
|
463
|
+
/** Returned once, never retrievable again. Persist it, then discard the bootstrap secret. */
|
|
464
|
+
credential: string;
|
|
465
|
+
accountId: string;
|
|
466
|
+
deviceId: string;
|
|
467
|
+
createdAt: string;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* POST /enroll — exchanges the bootstrap secret for this device's own credential.
|
|
472
|
+
* Rejects with err.code 'ENROLLMENT_REJECTED' (401, secret not accepted),
|
|
473
|
+
* 'ENROLLMENT_UNSUPPORTED' (404, server runs shared mode or predates enrollment),
|
|
474
|
+
* or 'VAULT_ERROR' with err.status for other failures.
|
|
475
|
+
*/
|
|
476
|
+
export function enrollVaultDevice(config: {
|
|
477
|
+
vaultUrl: string;
|
|
478
|
+
enrollmentSecret: string;
|
|
479
|
+
accountId: string;
|
|
480
|
+
deviceId: string;
|
|
481
|
+
fetchImpl?: typeof fetch;
|
|
482
|
+
}): Promise<VaultEnrollment>;
|
|
483
|
+
|
|
428
484
|
export interface DbSyncEngineConfig {
|
|
429
485
|
// Identity / local state
|
|
430
486
|
storageKeyPrefix: string;
|
|
@@ -471,6 +527,74 @@ export interface DbSyncResult {
|
|
|
471
527
|
applied: number;
|
|
472
528
|
skipped: number;
|
|
473
529
|
skippedEntityIds: string[];
|
|
530
|
+
/** True when the push threw but the pull still ran (Phase 3.3 partial function). */
|
|
531
|
+
pushFailed?: boolean;
|
|
532
|
+
/** True when the push was skipped because its backoff window is open. */
|
|
533
|
+
pushSkipped?: boolean;
|
|
534
|
+
/** The push error's code when pushFailed/pushSkipped — the pull's own result is still reported above. */
|
|
535
|
+
pushErrorCode?: SyncErrorCode | string;
|
|
536
|
+
/** True when the pull threw but the cursor report still ran. */
|
|
537
|
+
pullFailed?: boolean;
|
|
538
|
+
/** True when the pull was skipped because its backoff window is open. */
|
|
539
|
+
pullSkipped?: boolean;
|
|
540
|
+
/** The pull error's code when pullFailed/pullSkipped. */
|
|
541
|
+
pullErrorCode?: SyncErrorCode | string;
|
|
542
|
+
/** The standing over-quota descriptor, when one applies. */
|
|
543
|
+
quota?: QuotaState | null;
|
|
544
|
+
/** True when the cycle no-opped because the credential halt is set. */
|
|
545
|
+
halted?: boolean;
|
|
546
|
+
/** True when the cycle no-opped because this instance's credential was superseded. */
|
|
547
|
+
superseded?: boolean;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Over-quota state (Phase 3.3). IN MEMORY ONLY — this describes server-side
|
|
552
|
+
* state that changes without the client (an operator raises a limit, reclaim
|
|
553
|
+
* runs, intents expire), so it is never persisted and there is nothing to
|
|
554
|
+
* clear: it lifts by itself when a write succeeds again.
|
|
555
|
+
*
|
|
556
|
+
* `quota` is the server's dimension string, passed through verbatim — the
|
|
557
|
+
* known values are 'storage' | 'rows' | 'intents' | 'concurrent-uploads', but
|
|
558
|
+
* a newer server may add dimensions, so treat an unrecognised one generically
|
|
559
|
+
* rather than assuming it cannot happen. The numbers are bytes for 'storage'
|
|
560
|
+
* and counts otherwise, and render "X of Y used" directly; they are null only
|
|
561
|
+
* in the defensive case where a rejection was typed without a full body.
|
|
562
|
+
*/
|
|
563
|
+
/**
|
|
564
|
+
* One backoff window. The DB engine keeps two (push and pull) and enforces
|
|
565
|
+
* them itself; both are IN MEMORY ONLY, like the file tier's, so a restart
|
|
566
|
+
* clears them — a relaunch is a user saying "try now", and a persisting
|
|
567
|
+
* failure re-opens the window immediately.
|
|
568
|
+
*
|
|
569
|
+
* A window only ever DELAYS: the next cycle after `until` probes again, with
|
|
570
|
+
* no user action and no restart. Failures that must not be delayed at all
|
|
571
|
+
* (PASSPHRASE_REQUIRED / ACCOUNT_ID_REQUIRED, which clear the moment the app
|
|
572
|
+
* supplies the value) and failures owned by another state (CREDENTIAL_INVALID,
|
|
573
|
+
* which halts) never open one.
|
|
574
|
+
*/
|
|
575
|
+
export interface BackoffWindow {
|
|
576
|
+
/** Epoch ms; the direction is skipped while this is in the future. 0 when clear. */
|
|
577
|
+
until: number;
|
|
578
|
+
/** Consecutive failures driving the escalation (30s doubling; 15 min push cap, 5 min pull cap). */
|
|
579
|
+
strikes: number;
|
|
580
|
+
/** 'quota' (keeps surfacing its descriptor), 'auth' (flat hour on a 401), 'transport' (quiet after one signal). */
|
|
581
|
+
reason: 'quota' | 'auth' | 'transport' | null;
|
|
582
|
+
/** The typed error code that opened the window. */
|
|
583
|
+
code: SyncErrorCode | string | null;
|
|
584
|
+
/** ISO timestamp of the most recent failure. */
|
|
585
|
+
since: string | null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export interface QuotaState {
|
|
589
|
+
quota: string;
|
|
590
|
+
limit: number | null;
|
|
591
|
+
used: number | null;
|
|
592
|
+
requested: number | null;
|
|
593
|
+
message: string;
|
|
594
|
+
/** ISO timestamp of the most recent rejection. */
|
|
595
|
+
since: string;
|
|
596
|
+
/** ISO timestamp after which the engine probes again, on its own. */
|
|
597
|
+
retryAt: string;
|
|
474
598
|
}
|
|
475
599
|
|
|
476
600
|
/** A row quarantined after failing to decrypt under the (verified) account key. */
|
|
@@ -510,7 +634,93 @@ export interface DbSyncEngine {
|
|
|
510
634
|
isKeyVerified(): boolean;
|
|
511
635
|
hasEncryptionReady(): boolean;
|
|
512
636
|
|
|
637
|
+
/** True while the persisted credential-rejected stop is set (CREDENTIAL_INVALID). */
|
|
638
|
+
isCredentialHalted(): boolean;
|
|
639
|
+
/** The persisted stop record, or null. Cleared only by recoverVaultSyncEngine after a verified successful re-enrollment. */
|
|
640
|
+
getCredentialHalt(): { message: string; at: string } | null;
|
|
641
|
+
/** True once this instance proved its bearer was superseded by recovery and went inert (in-memory only). */
|
|
642
|
+
isSuperseded(): boolean;
|
|
643
|
+
/** The standing over-quota descriptor, or null. In-memory only; never a hard stop; nothing to clear. */
|
|
644
|
+
getQuotaState(): QuotaState | null;
|
|
645
|
+
/** True while the bounded over-quota window is open (writes skipped, pulls continue). */
|
|
646
|
+
isQuotaSuppressed(): boolean;
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Epoch ms before which the push/pull will not be attempted, or 0. Same
|
|
650
|
+
* getter names and meaning as the file engine's, so one scheduler can treat
|
|
651
|
+
* both tiers identically — but unlike the file tier, the DB engine also
|
|
652
|
+
* ENFORCES these itself, so honouring them is an optimisation, not a duty.
|
|
653
|
+
*/
|
|
654
|
+
getUploadBackoffUntil(): number;
|
|
655
|
+
getDownloadBackoffUntil(): number;
|
|
656
|
+
/**
|
|
657
|
+
* The full backoff picture. `reason` distinguishes a one-hour auth window
|
|
658
|
+
* from a thirty-second transport one without inferring from the timestamp's
|
|
659
|
+
* magnitude, which is what an app needs to tell a user why and for how long.
|
|
660
|
+
*/
|
|
661
|
+
getBackoffState(): { push: BackoffWindow; pull: BackoffWindow };
|
|
662
|
+
/** The resolved stable device identity this engine runs as. */
|
|
663
|
+
deviceId: string;
|
|
664
|
+
|
|
513
665
|
vault: VaultClient;
|
|
514
666
|
}
|
|
515
667
|
|
|
516
668
|
export function createDbSyncEngine(config: DbSyncEngineConfig): DbSyncEngine;
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Package-owned stable device identifier: generated once (crypto.randomUUID)
|
|
672
|
+
* and persisted under `{prefix}-device-id`. Used for both the device cursor
|
|
673
|
+
* and per-account enrollment. An explicit config.deviceId overrides it.
|
|
674
|
+
*/
|
|
675
|
+
export function getOrCreateDeviceId(storageKeyPrefix: string): string;
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* The packaged vault connect flow: discovers the server's auth mode, resolves
|
|
679
|
+
* the Bearer value (enrolling with the bootstrap secret when the server is
|
|
680
|
+
* per-account and no credential is stored yet), persists the credential under
|
|
681
|
+
* `{prefix}-vault-credential`, and returns a ready engine. The bootstrap
|
|
682
|
+
* secret is never written to storage, never logged, and never retained past
|
|
683
|
+
* the call. No path re-enrolls automatically once a credential is stored.
|
|
684
|
+
*
|
|
685
|
+
* Typed failures: VAULT_TOKEN_REQUIRED, ENROLLMENT_SECRET_REQUIRED,
|
|
686
|
+
* CREDENTIAL_PERSIST_FAILED, VAULT_UNREACHABLE, plus enrollVaultDevice's
|
|
687
|
+
* ENROLLMENT_REJECTED / ENROLLMENT_UNSUPPORTED.
|
|
688
|
+
*/
|
|
689
|
+
export function connectVaultSyncEngine(
|
|
690
|
+
config: Omit<DbSyncEngineConfig, 'vaultToken'> & {
|
|
691
|
+
vaultToken?: string;
|
|
692
|
+
enrollmentSecret?: string;
|
|
693
|
+
}
|
|
694
|
+
): Promise<{
|
|
695
|
+
engine: DbSyncEngine;
|
|
696
|
+
/** null when discovery failed and the flow fell back to the last known auth state. */
|
|
697
|
+
authMode: VaultAuthMode | null;
|
|
698
|
+
/** True only when this call minted a credential. */
|
|
699
|
+
enrolled: boolean;
|
|
700
|
+
deviceId: string;
|
|
701
|
+
}>;
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* The exit from the credential halt (Phase 2.2): USER-INITIATED re-enrollment
|
|
705
|
+
* with the bootstrap secret. Refuses unless the device is halted (NOT_HALTED)
|
|
706
|
+
* and unless the server runs per-account auth (RECOVERY_UNSUPPORTED /
|
|
707
|
+
* VAULT_UNREACHABLE — recovery never falls back on discovery failure). The
|
|
708
|
+
* stale record's deviceId is used for enrollment so the server (2.1) revokes
|
|
709
|
+
* every still-active predecessor; an explicit config.deviceId differing from
|
|
710
|
+
* it is DEVICE_ID_CONFLICT. Order: canary -> enroll -> persist + verify
|
|
711
|
+
* (overwriting the stale record) -> clear the halt LAST -> fresh engine.
|
|
712
|
+
* Every failure leaves the device halted with its state intact. The secret is
|
|
713
|
+
* confined to the call, exactly as in connectVaultSyncEngine. Call only from
|
|
714
|
+
* a deliberate user action; no code path in this package invokes it.
|
|
715
|
+
*/
|
|
716
|
+
export function recoverVaultSyncEngine(
|
|
717
|
+
config: Omit<DbSyncEngineConfig, 'vaultToken'> & {
|
|
718
|
+
vaultToken?: string;
|
|
719
|
+
enrollmentSecret: string;
|
|
720
|
+
}
|
|
721
|
+
): Promise<{
|
|
722
|
+
engine: DbSyncEngine;
|
|
723
|
+
authMode: 'per-account';
|
|
724
|
+
enrolled: true;
|
|
725
|
+
deviceId: string;
|
|
726
|
+
}>;
|