@opengeni/db 3.1.3-canary.38890816741001 → 3.2.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/{chunk-JMX6XSIF.js → chunk-V2X2NK7T.js} +55 -4
- package/dist/{chunk-JMX6XSIF.js.map → chunk-V2X2NK7T.js.map} +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -1
- package/dist/migrate.js +1 -1
- package/dist/vaults.d.ts +68 -3
- package/drizzle/0789_external_vault_sources.sql +318 -0
- package/package.json +9 -9
- package/src/index.ts +73 -9
- package/src/vaults.ts +84 -2
package/dist/index.d.ts
CHANGED
|
@@ -9276,6 +9276,11 @@ export declare function claimWorkspaceArchiveCapture(db: Database, input: {
|
|
|
9276
9276
|
* exact box are recorded in deadline_forced_admission_ids, captured
|
|
9277
9277
|
* around, and settled with the box after termination. */
|
|
9278
9278
|
deadlineMandatoryCaptureLeadMs?: number;
|
|
9279
|
+
/** Draining only: unsettled setup requests whose provider Start outcome
|
|
9280
|
+
* is unknown, owned by a turn attempt closed at least this long ago with
|
|
9281
|
+
* no active retained process, are captured around exactly like the
|
|
9282
|
+
* mandatory pre-deadline save instead of pinning the box until then. */
|
|
9283
|
+
abandonedSetupCaptureAfterMs?: number;
|
|
9279
9284
|
/** Warm point-in-time checkpoint of a box no turn holds, kept warm by
|
|
9280
9285
|
* viewers, browser/computer interactions or active background commands
|
|
9281
9286
|
* (supervised or not) (e.g. while the session waits for one or for input). Requires
|
package/dist/index.js
CHANGED
|
@@ -1534,6 +1534,7 @@ import {
|
|
|
1534
1534
|
placeSubscriptionCoreTurnWithFailover,
|
|
1535
1535
|
planWorkspaceCaptureGc,
|
|
1536
1536
|
preflightOrganizationUserSetup,
|
|
1537
|
+
preflightProviderVaultLink,
|
|
1537
1538
|
prepareBotMessage,
|
|
1538
1539
|
prepareBrowserDownloadSave,
|
|
1539
1540
|
prepareBrowserRevisionPublication,
|
|
@@ -2207,7 +2208,7 @@ import {
|
|
|
2207
2208
|
writeCompanyProfileLearning,
|
|
2208
2209
|
writeSessionContentBlobs,
|
|
2209
2210
|
writeSiteDataPath
|
|
2210
|
-
} from "./chunk-
|
|
2211
|
+
} from "./chunk-V2X2NK7T.js";
|
|
2211
2212
|
import {
|
|
2212
2213
|
BROKERED_CREDENTIAL_SHAPE_HINT,
|
|
2213
2214
|
ConnectionRefreshHttpError,
|
|
@@ -4860,6 +4861,7 @@ export {
|
|
|
4860
4861
|
placeSubscriptionCoreTurnWithFailover,
|
|
4861
4862
|
planWorkspaceCaptureGc,
|
|
4862
4863
|
preflightOrganizationUserSetup,
|
|
4864
|
+
preflightProviderVaultLink,
|
|
4863
4865
|
prepareBotMessage,
|
|
4864
4866
|
prepareBrowserDownloadSave,
|
|
4865
4867
|
prepareBrowserRevisionPublication,
|
package/dist/migrate.js
CHANGED
package/dist/vaults.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { VaultErrorCode, type SecretPrincipal, type VaultFieldKind, type VaultItemType, type VaultMode, type VaultOwnerKind, type VaultPolicy, type VaultSource, type VaultSyncStatus, type VaultVersionCreatedByKind } from "@opengeni/contracts";
|
|
1
|
+
import { VaultErrorCode, type SecretPrincipal, type VaultFieldKind, type VaultItemType, type VaultMode, type VaultOwnerKind, type VaultPolicy, type VaultProviderOperations, type VaultSource, type VaultSyncStatus, type VaultVersionCreatedByKind } from "@opengeni/contracts";
|
|
2
2
|
import { type Database } from "./database.js";
|
|
3
3
|
export type VaultDbContext = {
|
|
4
4
|
kind: "subject";
|
|
@@ -19,6 +19,15 @@ export declare class VaultError extends Error {
|
|
|
19
19
|
export declare function isVaultError(error: unknown): error is VaultError;
|
|
20
20
|
/** Map a database error raised by a vault routine to VaultError; null otherwise. */
|
|
21
21
|
export declare function vaultErrorFromDatabase(error: unknown): VaultError | null;
|
|
22
|
+
/**
|
|
23
|
+
* What a provider (or its bridge) said about its own upstream freshness at
|
|
24
|
+
* the last successful check (0789). Opengeni's own check time is
|
|
25
|
+
* `lastSyncedAt`; it never stands in for `sourceSyncedAt`.
|
|
26
|
+
*/
|
|
27
|
+
export type StoredProviderFreshness = {
|
|
28
|
+
source: "live" | "cache" | "unknown";
|
|
29
|
+
sourceSyncedAt: string | null;
|
|
30
|
+
};
|
|
22
31
|
export type VaultRow = {
|
|
23
32
|
id: string;
|
|
24
33
|
workspaceId: string;
|
|
@@ -36,6 +45,15 @@ export type VaultRow = {
|
|
|
36
45
|
rateLimitedUntil: string | null;
|
|
37
46
|
providerConnectionId: string | null;
|
|
38
47
|
providerVaultRef: string | null;
|
|
48
|
+
/** Effective operations the provider last reported (0789); null before the first check. */
|
|
49
|
+
providerOperations?: VaultProviderOperations | null;
|
|
50
|
+
/** The provider's own freshness observation from the last successful check (0789). */
|
|
51
|
+
providerFreshness?: StoredProviderFreshness | null;
|
|
52
|
+
/**
|
|
53
|
+
* Durable value-cache epoch (0789, bigint as text). Every complete mirror
|
|
54
|
+
* replacement advances it; provider value caches compare it before reuse.
|
|
55
|
+
*/
|
|
56
|
+
providerCacheEpoch?: string;
|
|
39
57
|
availability: {
|
|
40
58
|
allowedWorkspaceIds: string[] | null;
|
|
41
59
|
allowPersonalWorkspaces: boolean;
|
|
@@ -105,6 +123,8 @@ export type VaultFieldCiphertext = {
|
|
|
105
123
|
source: VaultSource;
|
|
106
124
|
providerConnectionId: string | null;
|
|
107
125
|
providerVaultRef: string | null;
|
|
126
|
+
/** The vault's value-cache epoch (0789). */
|
|
127
|
+
providerCacheEpoch?: string;
|
|
108
128
|
providerItemRef: string | null;
|
|
109
129
|
providerFieldRef: string | null;
|
|
110
130
|
};
|
|
@@ -137,6 +157,8 @@ export type SecretReferenceCandidate = {
|
|
|
137
157
|
source: VaultSource;
|
|
138
158
|
providerConnectionId: string | null;
|
|
139
159
|
providerVaultRef: string | null;
|
|
160
|
+
/** The vault's value-cache epoch (0789). */
|
|
161
|
+
providerCacheEpoch?: string;
|
|
140
162
|
itemId: string;
|
|
141
163
|
itemName: string;
|
|
142
164
|
itemType: VaultItemType;
|
|
@@ -349,24 +371,57 @@ export type ProviderVaultItemMirror = {
|
|
|
349
371
|
kind: VaultFieldKind;
|
|
350
372
|
}>;
|
|
351
373
|
};
|
|
374
|
+
/**
|
|
375
|
+
* Replace a connected vault's metadata mirror with one complete inventory
|
|
376
|
+
* (at most `VAULT_LIMITS.itemsPerVault` items; more is refused with
|
|
377
|
+
* `provider_inventory_incomplete` and the old mirror kept). Items missing
|
|
378
|
+
* from `items` are removed, and the vault's cache epoch advances (0789).
|
|
379
|
+
*/
|
|
352
380
|
export declare function upsertProviderVaultItems(db: Database, context: VaultDbContext, input: {
|
|
353
381
|
vaultId: string;
|
|
354
382
|
contentVersion: string | null;
|
|
355
383
|
items: ProviderVaultItemMirror[];
|
|
356
384
|
}): Promise<void>;
|
|
385
|
+
/**
|
|
386
|
+
* Record a connected vault's sync state. `operations` and `freshness` are
|
|
387
|
+
* optional (0789): absent keeps the stored value, null clears it. The optional
|
|
388
|
+
* mirror and status settle atomically, comparing `expectedCacheEpoch` under
|
|
389
|
+
* the vault lock; a mismatch raises provider_conflict with superseded=true.
|
|
390
|
+
* Every applied settlement advances the epoch, even unchanged or failed checks.
|
|
391
|
+
*/
|
|
357
392
|
export declare function setVaultSyncStatus(db: Database, context: VaultDbContext, input: {
|
|
358
393
|
vaultId: string;
|
|
359
394
|
status: VaultSyncStatus;
|
|
360
395
|
errorCode?: string | null;
|
|
361
396
|
errorMessage?: string | null;
|
|
362
397
|
rateLimitedUntil?: string | null;
|
|
398
|
+
/** The provider's effective operations for this vault. */
|
|
399
|
+
operations?: VaultProviderOperations | null;
|
|
400
|
+
/** The provider's own upstream freshness observation (never Opengeni's check time). */
|
|
401
|
+
freshness?: StoredProviderFreshness | null;
|
|
402
|
+
/** Reject a late refresh when another settlement advanced this epoch. */
|
|
403
|
+
expectedCacheEpoch?: string;
|
|
404
|
+
/** Complete inventory committed atomically with this successful status. */
|
|
405
|
+
mirror?: {
|
|
406
|
+
contentVersion: string | null;
|
|
407
|
+
items: ProviderVaultItemMirror[];
|
|
408
|
+
};
|
|
363
409
|
}): Promise<void>;
|
|
364
|
-
/**
|
|
410
|
+
/**
|
|
411
|
+
* Link a connected vault (W5, 0789). Every source but `host` needs an active
|
|
412
|
+
* `api_key` connection in the caller's workspace whose provider identity
|
|
413
|
+
* matches (`1password.com` with the 1Password service-account role, or
|
|
414
|
+
* `vault:<source>` with the `external_vault_provider` role and
|
|
415
|
+
* `metadata.vaultProvider.provider = source`, version 1, not pending
|
|
416
|
+
* verification) and whose ownership matches `ownerKind`: a personal
|
|
417
|
+
* connection backs only its owner's Only-me vaults. The mirror is filled by
|
|
418
|
+
* the first sync.
|
|
419
|
+
*/
|
|
365
420
|
export declare function linkProviderVault(db: Database, context: VaultDbContext, input: {
|
|
366
421
|
ownerKind: "user" | "workspace" | "organization";
|
|
367
422
|
name: string;
|
|
368
423
|
description?: string | null;
|
|
369
|
-
source:
|
|
424
|
+
source: Exclude<VaultSource, "opengeni">;
|
|
370
425
|
providerConnectionId?: string | null;
|
|
371
426
|
providerVaultRef: string;
|
|
372
427
|
mode: VaultMode;
|
|
@@ -377,6 +432,14 @@ export declare function linkProviderVault(db: Database, context: VaultDbContext,
|
|
|
377
432
|
/** The caller holds account:admin. */
|
|
378
433
|
allowOrganization?: boolean;
|
|
379
434
|
}): Promise<VaultRow>;
|
|
435
|
+
/**
|
|
436
|
+
* Exercise the exact link policy before an irreversible provider create. The
|
|
437
|
+
* temporary link, personal authority and audit rows are all rolled back. This
|
|
438
|
+
* is a preflight, not a reservation: a race after it must return the confirmed
|
|
439
|
+
* upstream identity so the caller can link that vault instead of creating again.
|
|
440
|
+
* No database transaction is held open across provider I/O.
|
|
441
|
+
*/
|
|
442
|
+
export declare function preflightProviderVaultLink(db: Database, context: VaultDbContext, input: Omit<Parameters<typeof linkProviderVault>[2], "providerVaultRef">): Promise<void>;
|
|
380
443
|
export type ProviderVaultSyncTargetRow = {
|
|
381
444
|
vaultId: string;
|
|
382
445
|
workspaceId: string;
|
|
@@ -386,6 +449,8 @@ export type ProviderVaultSyncTargetRow = {
|
|
|
386
449
|
providerVaultRef: string;
|
|
387
450
|
providerContentVersion: string | null;
|
|
388
451
|
rateLimitedUntil: string | null;
|
|
452
|
+
/** The vault's value-cache epoch (0789). */
|
|
453
|
+
providerCacheEpoch?: string;
|
|
389
454
|
};
|
|
390
455
|
/** The provider refs and mirror version of one reachable connected vault. */
|
|
391
456
|
export declare function getProviderVaultSyncTarget(db: Database, context: VaultDbContext, vaultId: string): Promise<ProviderVaultSyncTargetRow>;
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
-- deployment-mode: rolling
|
|
2
|
+
-- External vault providers (docs/design/external-vaults-2026-10-15.md, sections
|
|
3
|
+
-- 6, 8, 9 and 10).
|
|
4
|
+
-- * New source identities: `external` (remote bridge), `protonpass`,
|
|
5
|
+
-- `keeper` and `bitwarden`. Each is linked through a connection, like
|
|
6
|
+
-- 1Password; `host` keeps its injected, connection-less provider.
|
|
7
|
+
-- * Linking checks provider identity, active connection status and
|
|
8
|
+
-- ownership here as well as in HTTP. A personal connection backs only its
|
|
9
|
+
-- owner's Only-me vaults; a shared connection backs only vaults of its own
|
|
10
|
+
-- ownership, so an organization connection never becomes a workspace one.
|
|
11
|
+
-- A legacy 1Password connection without recorded ownership stays a
|
|
12
|
+
-- workspace connection, as the API has always shown it.
|
|
13
|
+
-- * A linked vault keeps the effective operations its provider last
|
|
14
|
+
-- reported, the provider's own freshness observation (never Opengeni's
|
|
15
|
+
-- check time), and a durable cache epoch. Every complete mirror
|
|
16
|
+
-- replacement advances the epoch, so per-attempt value caches in any API
|
|
17
|
+
-- or worker process notice a refresh made elsewhere.
|
|
18
|
+
-- * A mirror replacement holds at most the native 2,000 items per vault; a
|
|
19
|
+
-- larger inventory is refused whole, never stored as its first 2,000.
|
|
20
|
+
-- * Mirror replacements of one vault run one at a time: each holds the
|
|
21
|
+
-- vault row lock from before it reads the mirror until it commits, so an
|
|
22
|
+
-- older listing cannot re-add an item that a newer one just removed and
|
|
23
|
+
-- leave it under the newer content version.
|
|
24
|
+
-- * Core refresh settlement compares the epoch read before provider I/O,
|
|
25
|
+
-- committing inventory and status together. Every settlement advances it;
|
|
26
|
+
-- late results cannot overwrite a newer completed check, even unchanged.
|
|
27
|
+
-- Existing rows keep their meaning: the new columns are NULL or 0 and the
|
|
28
|
+
-- Only-me availability default of 0780 is unchanged.
|
|
29
|
+
SET LOCAL lock_timeout = '5s';
|
|
30
|
+
|
|
31
|
+
ALTER TABLE vaults DROP CONSTRAINT vaults_source_check;
|
|
32
|
+
ALTER TABLE vaults DROP CONSTRAINT vaults_provider_shape;
|
|
33
|
+
ALTER TABLE vaults
|
|
34
|
+
ADD COLUMN provider_operations jsonb,
|
|
35
|
+
ADD COLUMN provider_freshness jsonb,
|
|
36
|
+
ADD COLUMN provider_cache_epoch bigint NOT NULL DEFAULT 0;
|
|
37
|
+
ALTER TABLE vaults ADD CONSTRAINT vaults_source_check CHECK (
|
|
38
|
+
source IN ('opengeni', 'onepassword', 'host', 'external', 'protonpass', 'keeper', 'bitwarden')
|
|
39
|
+
);
|
|
40
|
+
ALTER TABLE vaults ADD CONSTRAINT vaults_provider_shape CHECK (
|
|
41
|
+
(source = 'opengeni' AND provider_vault_ref IS NULL AND provider_connection_id IS NULL
|
|
42
|
+
AND mode = 'read_write')
|
|
43
|
+
OR (source IN ('onepassword', 'external', 'protonpass', 'keeper', 'bitwarden')
|
|
44
|
+
AND provider_vault_ref IS NOT NULL AND provider_connection_id IS NOT NULL)
|
|
45
|
+
OR (source = 'host' AND provider_vault_ref IS NOT NULL AND provider_connection_id IS NULL)
|
|
46
|
+
);
|
|
47
|
+
ALTER TABLE vaults ADD CONSTRAINT vaults_provider_metadata_shape CHECK (
|
|
48
|
+
provider_cache_epoch >= 0
|
|
49
|
+
AND (provider_operations IS NULL
|
|
50
|
+
OR (source <> 'opengeni' AND jsonb_typeof(provider_operations) = 'object'))
|
|
51
|
+
AND (provider_freshness IS NULL
|
|
52
|
+
OR (source <> 'opengeni' AND jsonb_typeof(provider_freshness) = 'object'))
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
-- Rewrites one routine in place. Each patch must match exactly once, so a
|
|
56
|
+
-- routine that changed since this was written fails the migration loudly.
|
|
57
|
+
-- The name is migration-specific because earlier migrations leave their own
|
|
58
|
+
-- patcher in the migrator session's pg_temp schema.
|
|
59
|
+
CREATE FUNCTION pg_temp.patch_vault_routine_0786(p_routine regprocedure, p_patches text[])
|
|
60
|
+
RETURNS void
|
|
61
|
+
LANGUAGE plpgsql AS $patch$
|
|
62
|
+
DECLARE
|
|
63
|
+
definition text := pg_catalog.pg_get_functiondef(p_routine);
|
|
64
|
+
patch text[];
|
|
65
|
+
BEGIN
|
|
66
|
+
FOREACH patch SLICE 1 IN ARRAY p_patches LOOP
|
|
67
|
+
IF (pg_catalog.length(definition) - pg_catalog.length(pg_catalog.replace(definition, patch[1], '')))
|
|
68
|
+
<> pg_catalog.length(patch[1]) THEN
|
|
69
|
+
RAISE EXCEPTION '0789 external vault sources: % changed near: %', p_routine, patch[1];
|
|
70
|
+
END IF;
|
|
71
|
+
definition := pg_catalog.replace(definition, patch[1], patch[2]);
|
|
72
|
+
END LOOP;
|
|
73
|
+
EXECUTE definition;
|
|
74
|
+
END
|
|
75
|
+
$patch$;
|
|
76
|
+
|
|
77
|
+
-- Summaries carry the provider's operations, freshness observation and cache epoch.
|
|
78
|
+
SELECT pg_temp.patch_vault_routine_0786('opengeni_private.vault_summary_json(vaults,jsonb)'::regprocedure, ARRAY[
|
|
79
|
+
ARRAY[$old$ 'providerVaultRef', p_vault.provider_vault_ref,
|
|
80
|
+
$old$,
|
|
81
|
+
$new$ 'providerVaultRef', p_vault.provider_vault_ref,
|
|
82
|
+
'providerOperations', p_vault.provider_operations,
|
|
83
|
+
'providerFreshness', p_vault.provider_freshness,
|
|
84
|
+
'providerCacheEpoch', p_vault.provider_cache_epoch::text,
|
|
85
|
+
$new$]
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
-- Link: every connected source needs an active connection of that provider
|
|
89
|
+
-- whose ownership matches the vault it backs.
|
|
90
|
+
SELECT pg_temp.patch_vault_routine_0786('link_provider_vault(jsonb,jsonb)'::regprocedure, ARRAY[
|
|
91
|
+
ARRAY[$old$ connection_row record;
|
|
92
|
+
$old$,
|
|
93
|
+
$new$ connection_row record;
|
|
94
|
+
connection_ownership text;
|
|
95
|
+
provider_label text;
|
|
96
|
+
$new$],
|
|
97
|
+
ARRAY[$old$ IF link_source NOT IN ('onepassword', 'host') OR link_source IS NULL THEN
|
|
98
|
+
PERFORM opengeni_private.vault_raise('vault_permission_denied', 'only 1Password and host vaults are linked');
|
|
99
|
+
END IF;
|
|
100
|
+
$old$,
|
|
101
|
+
$new$ IF link_source NOT IN ('onepassword', 'host', 'external', 'protonpass', 'keeper', 'bitwarden')
|
|
102
|
+
OR link_source IS NULL THEN
|
|
103
|
+
PERFORM opengeni_private.vault_raise('vault_permission_denied', 'only connected vault providers are linked');
|
|
104
|
+
END IF;
|
|
105
|
+
$new$],
|
|
106
|
+
ARRAY[$old$ IF link_source = 'onepassword' THEN
|
|
107
|
+
SELECT connection.id, connection.subject_id, connection.status INTO connection_row
|
|
108
|
+
FROM connections connection
|
|
109
|
+
WHERE connection.id = connection_ref
|
|
110
|
+
AND connection.account_id = (context ->> 'accountId')::uuid
|
|
111
|
+
AND connection.workspace_id = (context ->> 'workspaceId')::uuid
|
|
112
|
+
AND pg_catalog.lower(connection.provider_domain) = '1password.com';
|
|
113
|
+
IF connection_row.id IS NULL THEN
|
|
114
|
+
PERFORM opengeni_private.vault_raise('vault_not_found', '1Password connection not found');
|
|
115
|
+
END IF;
|
|
116
|
+
IF connection_row.subject_id IS NOT NULL AND (
|
|
117
|
+
owner_kind <> 'user' OR connection_row.subject_id IS DISTINCT FROM context ->> 'subjectId'
|
|
118
|
+
) THEN
|
|
119
|
+
PERFORM opengeni_private.vault_raise('vault_permission_denied',
|
|
120
|
+
'a personal 1Password connection backs only its owner''s Only-me vaults');
|
|
121
|
+
END IF;
|
|
122
|
+
ELSE
|
|
123
|
+
$old$,
|
|
124
|
+
$new$ IF link_source <> 'host' THEN
|
|
125
|
+
provider_label := CASE link_source
|
|
126
|
+
WHEN 'onepassword' THEN '1Password'
|
|
127
|
+
WHEN 'protonpass' THEN 'Proton Pass'
|
|
128
|
+
WHEN 'keeper' THEN 'Keeper'
|
|
129
|
+
WHEN 'bitwarden' THEN 'Bitwarden'
|
|
130
|
+
ELSE 'vault provider' END;
|
|
131
|
+
SELECT connection.id, connection.subject_id, connection.status, connection.metadata
|
|
132
|
+
INTO connection_row
|
|
133
|
+
FROM connections connection
|
|
134
|
+
WHERE connection.id = connection_ref
|
|
135
|
+
AND connection.account_id = (context ->> 'accountId')::uuid
|
|
136
|
+
AND connection.workspace_id = (context ->> 'workspaceId')::uuid
|
|
137
|
+
AND connection.kind = 'api_key'
|
|
138
|
+
AND CASE WHEN link_source = 'onepassword' THEN
|
|
139
|
+
pg_catalog.lower(connection.provider_domain) = '1password.com'
|
|
140
|
+
AND connection.metadata ->> 'credentialRole' = 'onepassword_service_account'
|
|
141
|
+
ELSE
|
|
142
|
+
connection.provider_domain = 'vault:' || link_source
|
|
143
|
+
AND connection.metadata ->> 'credentialRole' = 'external_vault_provider'
|
|
144
|
+
AND connection.metadata #>> '{vaultProvider,provider}' = link_source
|
|
145
|
+
AND connection.metadata #>> '{vaultProvider,version}' = '1'
|
|
146
|
+
END;
|
|
147
|
+
IF connection_row.id IS NULL OR connection_row.status = 'revoked' THEN
|
|
148
|
+
PERFORM opengeni_private.vault_raise('vault_not_found', provider_label || ' connection not found');
|
|
149
|
+
END IF;
|
|
150
|
+
IF connection_row.status <> 'active'
|
|
151
|
+
OR coalesce(connection_row.metadata #>> '{vaultProvider,verificationStatus}', 'ready') <> 'ready' THEN
|
|
152
|
+
PERFORM opengeni_private.vault_raise('provider_auth_expired',
|
|
153
|
+
'the ' || provider_label || ' connection must be verified again before its vaults are linked',
|
|
154
|
+
pg_catalog.jsonb_build_object('connectionId', connection_row.id));
|
|
155
|
+
END IF;
|
|
156
|
+
connection_ownership := CASE
|
|
157
|
+
WHEN link_source <> 'onepassword' THEN connection_row.metadata #>> '{vaultProvider,ownership}'
|
|
158
|
+
WHEN connection_row.subject_id IS NOT NULL THEN 'user'
|
|
159
|
+
WHEN connection_row.metadata ->> 'ownership' = 'organization' THEN 'organization'
|
|
160
|
+
WHEN connection_row.metadata ->> 'ownership' = 'user' THEN NULL
|
|
161
|
+
ELSE 'workspace' END;
|
|
162
|
+
IF connection_ownership IS NULL
|
|
163
|
+
OR connection_ownership NOT IN ('user', 'workspace', 'organization')
|
|
164
|
+
OR (connection_ownership = 'user') <> (connection_row.subject_id IS NOT NULL) THEN
|
|
165
|
+
PERFORM opengeni_private.vault_raise('vault_permission_denied',
|
|
166
|
+
'the ' || provider_label || ' connection has no consistent owner',
|
|
167
|
+
pg_catalog.jsonb_build_object('connectionId', connection_row.id));
|
|
168
|
+
END IF;
|
|
169
|
+
IF connection_ownership = 'user' AND (
|
|
170
|
+
owner_kind <> 'user' OR connection_row.subject_id IS DISTINCT FROM context ->> 'subjectId'
|
|
171
|
+
) THEN
|
|
172
|
+
PERFORM opengeni_private.vault_raise('vault_permission_denied',
|
|
173
|
+
'a personal ' || provider_label || ' connection backs only its owner''s Only-me vaults');
|
|
174
|
+
END IF;
|
|
175
|
+
IF connection_ownership <> owner_kind THEN
|
|
176
|
+
PERFORM opengeni_private.vault_raise('vault_permission_denied',
|
|
177
|
+
'a ' || connection_ownership || ' ' || provider_label || ' connection backs only '
|
|
178
|
+
|| connection_ownership || ' vaults',
|
|
179
|
+
pg_catalog.jsonb_build_object('connectionId', connection_row.id,
|
|
180
|
+
'connectionOwnership', connection_ownership, 'ownerKind', owner_kind));
|
|
181
|
+
END IF;
|
|
182
|
+
ELSE
|
|
183
|
+
$new$]
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
-- Mirror replacement: a complete inventory of at most 2,000 items, applied
|
|
187
|
+
-- under the vault row lock, and every replacement advances the cache epoch.
|
|
188
|
+
-- The lock is FOR NO KEY UPDATE, the strength the routine's own final UPDATE
|
|
189
|
+
-- of the vault takes anyway, so it adds no new conflicts: it is only taken
|
|
190
|
+
-- before the mirror is read rather than after it is rewritten. Every later
|
|
191
|
+
-- statement sees what a replacement that held the lock before committed.
|
|
192
|
+
SELECT pg_temp.patch_vault_routine_0786('upsert_provider_vault_items(uuid,text,jsonb,jsonb)'::regprocedure, ARRAY[
|
|
193
|
+
ARRAY[$old$ kept_refs := ARRAY(SELECT element ->> 'providerItemRef'
|
|
194
|
+
$old$,
|
|
195
|
+
$new$ SELECT locked.* INTO vault_row FROM vaults locked
|
|
196
|
+
WHERE locked.id = vault_row.id
|
|
197
|
+
FOR NO KEY UPDATE OF locked;
|
|
198
|
+
IF vault_row.id IS NULL THEN
|
|
199
|
+
PERFORM opengeni_private.vault_raise('vault_not_found', 'vault not found');
|
|
200
|
+
ELSIF vault_row.source = 'opengeni' THEN
|
|
201
|
+
PERFORM opengeni_private.vault_raise('vault_permission_denied', 'only connected vaults have a provider mirror');
|
|
202
|
+
END IF;
|
|
203
|
+
IF pg_catalog.jsonb_typeof(coalesce(p_items, '[]'::jsonb)) <> 'array' THEN
|
|
204
|
+
PERFORM opengeni_private.vault_raise('provider_inventory_incomplete',
|
|
205
|
+
'the provider inventory is malformed; the previous inventory was kept');
|
|
206
|
+
ELSIF pg_catalog.jsonb_array_length(p_items) > 2000 THEN
|
|
207
|
+
PERFORM opengeni_private.vault_raise('provider_inventory_incomplete',
|
|
208
|
+
'a connected vault mirrors at most 2000 items; the previous inventory was kept',
|
|
209
|
+
pg_catalog.jsonb_build_object('vaultId', vault_row.id, 'limit', 2000));
|
|
210
|
+
END IF;
|
|
211
|
+
kept_refs := ARRAY(SELECT element ->> 'providerItemRef'
|
|
212
|
+
$new$],
|
|
213
|
+
ARRAY[$old$ UPDATE vaults SET provider_content_version = p_content_version, last_synced_at = pg_catalog.now(),
|
|
214
|
+
$old$,
|
|
215
|
+
$new$ UPDATE vaults SET provider_content_version = p_content_version, last_synced_at = pg_catalog.now(),
|
|
216
|
+
provider_cache_epoch = provider_cache_epoch + 1,
|
|
217
|
+
$new$]
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
-- Sync status also records the provider's effective operations and its own
|
|
221
|
+
-- freshness observation (both optional; a JSON null clears them).
|
|
222
|
+
SELECT pg_temp.patch_vault_routine_0786('set_vault_sync_status(uuid,jsonb,jsonb)'::regprocedure, ARRAY[
|
|
223
|
+
ARRAY[$old$ UPDATE vaults SET
|
|
224
|
+
sync_status = p ->> 'status',
|
|
225
|
+
$old$,
|
|
226
|
+
$new$ IF p ? 'operations' AND (
|
|
227
|
+
pg_catalog.jsonb_typeof(p -> 'operations') NOT IN ('object', 'null')
|
|
228
|
+
OR pg_catalog.octet_length((p -> 'operations')::text) > 4096) THEN
|
|
229
|
+
PERFORM opengeni_private.vault_raise('vault_permission_denied',
|
|
230
|
+
'provider operations must be a small JSON object');
|
|
231
|
+
END IF;
|
|
232
|
+
IF p ? 'freshness' AND (
|
|
233
|
+
pg_catalog.jsonb_typeof(p -> 'freshness') NOT IN ('object', 'null')
|
|
234
|
+
OR pg_catalog.octet_length((p -> 'freshness')::text) > 1024) THEN
|
|
235
|
+
PERFORM opengeni_private.vault_raise('vault_permission_denied',
|
|
236
|
+
'provider freshness must be a small JSON object');
|
|
237
|
+
END IF;
|
|
238
|
+
UPDATE vaults SET
|
|
239
|
+
sync_status = p ->> 'status',
|
|
240
|
+
$new$],
|
|
241
|
+
ARRAY[$old$ last_synced_at = CASE WHEN p ->> 'status' = 'ok' THEN pg_catalog.now() ELSE last_synced_at END,
|
|
242
|
+
$old$,
|
|
243
|
+
$new$ last_synced_at = CASE WHEN p ->> 'status' = 'ok' THEN pg_catalog.now() ELSE last_synced_at END,
|
|
244
|
+
provider_operations = CASE WHEN p ? 'operations'
|
|
245
|
+
THEN nullif(p -> 'operations', 'null'::jsonb) ELSE provider_operations END,
|
|
246
|
+
provider_freshness = CASE WHEN p ? 'freshness'
|
|
247
|
+
THEN nullif(p -> 'freshness', 'null'::jsonb) ELSE provider_freshness END,
|
|
248
|
+
$new$]
|
|
249
|
+
]);
|
|
250
|
+
|
|
251
|
+
-- What a sync, a read and a resolution need to key provider value caches.
|
|
252
|
+
-- Settle a refresh under one compare-and-set fence. Provider versions are
|
|
253
|
+
-- opaque: the cache epoch observed before I/O is the only ordering token.
|
|
254
|
+
-- Every settlement advances it, including unchanged checks and failures.
|
|
255
|
+
-- A late result changes neither the mirror nor the newer status/freshness.
|
|
256
|
+
-- The optional mirror uses the same canonical writer in this transaction;
|
|
257
|
+
-- its capability closes on return, so reopen ours before the status UPDATE.
|
|
258
|
+
SELECT pg_temp.patch_vault_routine_0786('set_vault_sync_status(uuid,jsonb,jsonb)'::regprocedure, ARRAY[
|
|
259
|
+
ARRAY[$old$ IF p ? 'operations' AND (
|
|
260
|
+
$old$,
|
|
261
|
+
$new$ SELECT locked.* INTO vault_row FROM vaults locked
|
|
262
|
+
WHERE locked.id = vault_row.id FOR NO KEY UPDATE OF locked;
|
|
263
|
+
IF vault_row.id IS NULL THEN
|
|
264
|
+
PERFORM opengeni_private.vault_raise('vault_not_found', 'vault not found');
|
|
265
|
+
END IF;
|
|
266
|
+
IF p ? 'expectedCacheEpoch'
|
|
267
|
+
AND (p ->> 'expectedCacheEpoch')::bigint IS DISTINCT FROM vault_row.provider_cache_epoch THEN
|
|
268
|
+
PERFORM opengeni_private.vault_raise('provider_conflict',
|
|
269
|
+
'another refresh settled first; this late inventory was not applied',
|
|
270
|
+
pg_catalog.jsonb_build_object('vaultId', vault_row.id, 'superseded', true));
|
|
271
|
+
END IF;
|
|
272
|
+
IF p ? 'operations' AND (
|
|
273
|
+
$new$],
|
|
274
|
+
ARRAY[$old$ UPDATE vaults SET
|
|
275
|
+
sync_status = p ->> 'status',
|
|
276
|
+
$old$,
|
|
277
|
+
$new$ IF p ? 'mirror' THEN
|
|
278
|
+
IF NOT (p ? 'expectedCacheEpoch') THEN
|
|
279
|
+
PERFORM opengeni_private.vault_raise('provider_conflict',
|
|
280
|
+
'atomic refresh settlement requires its pre-I/O cache epoch');
|
|
281
|
+
END IF;
|
|
282
|
+
IF p ->> 'status' IS DISTINCT FROM 'ok' OR pg_catalog.jsonb_typeof(p -> 'mirror') <> 'object' THEN
|
|
283
|
+
PERFORM opengeni_private.vault_raise('provider_inventory_incomplete',
|
|
284
|
+
'only a successful refresh may replace the inventory');
|
|
285
|
+
END IF;
|
|
286
|
+
PERFORM upsert_provider_vault_items(p_vault, p #>> '{mirror,contentVersion}',
|
|
287
|
+
p #> '{mirror,items}', p_principal);
|
|
288
|
+
PERFORM opengeni_private.vault_capability_open('write');
|
|
289
|
+
END IF;
|
|
290
|
+
UPDATE vaults SET
|
|
291
|
+
provider_cache_epoch = provider_cache_epoch + CASE WHEN p ? 'mirror' THEN 0 ELSE 1 END,
|
|
292
|
+
sync_status = p ->> 'status',
|
|
293
|
+
$new$]
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
SELECT pg_temp.patch_vault_routine_0786('provider_vault_sync_target(uuid,jsonb)'::regprocedure, ARRAY[
|
|
297
|
+
ARRAY[$old$ 'providerContentVersion', vault_row.provider_content_version,
|
|
298
|
+
$old$,
|
|
299
|
+
$new$ 'providerContentVersion', vault_row.provider_content_version,
|
|
300
|
+
'providerCacheEpoch', vault_row.provider_cache_epoch::text,
|
|
301
|
+
$new$]
|
|
302
|
+
]);
|
|
303
|
+
SELECT pg_temp.patch_vault_routine_0786('read_vault_field_ciphertext(uuid,integer,text,jsonb,jsonb)'::regprocedure, ARRAY[
|
|
304
|
+
ARRAY[$old$ 'providerVaultRef', vault_row.provider_vault_ref,
|
|
305
|
+
$old$,
|
|
306
|
+
$new$ 'providerVaultRef', vault_row.provider_vault_ref,
|
|
307
|
+
'providerCacheEpoch', vault_row.provider_cache_epoch::text,
|
|
308
|
+
$new$]
|
|
309
|
+
]);
|
|
310
|
+
SELECT pg_temp.patch_vault_routine_0786('resolve_secret_reference_candidates(jsonb,jsonb)'::regprocedure, ARRAY[
|
|
311
|
+
ARRAY[$old$ 'providerVaultRef', vault.provider_vault_ref,
|
|
312
|
+
$old$,
|
|
313
|
+
$new$ 'providerVaultRef', vault.provider_vault_ref,
|
|
314
|
+
'providerCacheEpoch', vault.provider_cache_epoch::text,
|
|
315
|
+
$new$]
|
|
316
|
+
]);
|
|
317
|
+
|
|
318
|
+
DROP FUNCTION pg_temp.patch_vault_routine_0786(regprocedure, text[]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/db",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.2",
|
|
4
4
|
"description": "Opengeni persistence: Drizzle schema, RLS-scoped query layer, the SQL migration runner, and role provisioning.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -117,14 +117,14 @@
|
|
|
117
117
|
"prepublishOnly": "bash ../../scripts/prepublish-guard"
|
|
118
118
|
},
|
|
119
119
|
"dependencies": {
|
|
120
|
-
"@opengeni/codemode": "3.
|
|
121
|
-
"@opengeni/codex": "3.
|
|
122
|
-
"@opengeni/config": "3.
|
|
123
|
-
"@opengeni/contracts": "3.
|
|
124
|
-
"@opengeni/network": "3.
|
|
125
|
-
"@opengeni/observability": "3.
|
|
126
|
-
"@opengeni/subscriptions": "3.
|
|
127
|
-
"@opengeni/xai-subscription": "3.
|
|
120
|
+
"@opengeni/codemode": "3.2.2",
|
|
121
|
+
"@opengeni/codex": "3.2.2",
|
|
122
|
+
"@opengeni/config": "3.2.2",
|
|
123
|
+
"@opengeni/contracts": "3.2.2",
|
|
124
|
+
"@opengeni/network": "3.2.2",
|
|
125
|
+
"@opengeni/observability": "3.2.2",
|
|
126
|
+
"@opengeni/subscriptions": "3.2.2",
|
|
127
|
+
"@opengeni/xai-subscription": "3.2.2",
|
|
128
128
|
"drizzle-orm": "^0.45.2",
|
|
129
129
|
"postgres": "^3.4.7"
|
|
130
130
|
},
|
package/src/index.ts
CHANGED
|
@@ -56039,6 +56039,11 @@ export async function claimWorkspaceArchiveCapture(
|
|
|
56039
56039
|
* exact box are recorded in deadline_forced_admission_ids, captured
|
|
56040
56040
|
* around, and settled with the box after termination. */
|
|
56041
56041
|
deadlineMandatoryCaptureLeadMs?: number;
|
|
56042
|
+
/** Draining only: unsettled setup requests whose provider Start outcome
|
|
56043
|
+
* is unknown, owned by a turn attempt closed at least this long ago with
|
|
56044
|
+
* no active retained process, are captured around exactly like the
|
|
56045
|
+
* mandatory pre-deadline save instead of pinning the box until then. */
|
|
56046
|
+
abandonedSetupCaptureAfterMs?: number;
|
|
56042
56047
|
/** Warm point-in-time checkpoint of a box no turn holds, kept warm by
|
|
56043
56048
|
* viewers, browser/computer interactions or active background commands
|
|
56044
56049
|
* (supervised or not) (e.g. while the session waits for one or for input). Requires
|
|
@@ -56341,27 +56346,86 @@ export async function claimWorkspaceArchiveCapture(
|
|
|
56341
56346
|
order by admission.id
|
|
56342
56347
|
`);
|
|
56343
56348
|
if (unsettled.length > 0) {
|
|
56344
|
-
//
|
|
56345
|
-
//
|
|
56349
|
+
// Close to the provider deadline a drain must save now. Due rotations
|
|
56350
|
+
// are only stamped on warm leases, so a lease that was already
|
|
56351
|
+
// draining (no holder left) never gets one; requiring it let such a
|
|
56352
|
+
// box be killed uncaptured at the deadline. This branch is draining
|
|
56353
|
+
// only, so the deadline alone decides.
|
|
56346
56354
|
const lead = input.deadlineMandatoryCaptureLeadMs;
|
|
56347
56355
|
const [deadline] =
|
|
56348
56356
|
input.liveness === "draining" && lead !== undefined && Number.isSafeInteger(lead)
|
|
56349
56357
|
? await scopedDb.execute<{ mandatory: boolean }>(sql`
|
|
56350
56358
|
select (
|
|
56351
|
-
${row.
|
|
56352
|
-
and ${row.provider_deadline_at ?? null}::timestamptz is not null
|
|
56359
|
+
${row.provider_deadline_at ?? null}::timestamptz is not null
|
|
56353
56360
|
and ${row.provider_deadline_at ?? null}::timestamptz
|
|
56354
56361
|
<= now() + (${lead}::bigint * interval '1 millisecond')
|
|
56355
56362
|
) as mandatory
|
|
56356
56363
|
`)
|
|
56357
56364
|
: [];
|
|
56358
|
-
|
|
56365
|
+
// A setup request whose provider Start outcome is unknown can never
|
|
56366
|
+
// settle on a live box: no exit can be observed for it, and its turn
|
|
56367
|
+
// waits (recovering) for the box to be gone. Waiting for the deadline
|
|
56368
|
+
// kept a box with no holder running for a day. Once its owner attempt
|
|
56369
|
+
// has been closed for the abandonment window, with no retained process
|
|
56370
|
+
// of that attempt, capture around exactly these setup requests;
|
|
56371
|
+
// termination then settles them and the turn's ended-epoch recovery
|
|
56372
|
+
// resumes it on the saved workspace. Only a point-in-time (Modal
|
|
56373
|
+
// snapshot) capture may run around a request that might still be
|
|
56374
|
+
// writing; a file-by-file tar read keeps waiting for the deadline.
|
|
56375
|
+
const abandonAfterMs = input.abandonedSetupCaptureAfterMs;
|
|
56376
|
+
const [abandoned] =
|
|
56377
|
+
deadline?.mandatory !== true &&
|
|
56378
|
+
input.liveness === "draining" &&
|
|
56379
|
+
input.pointInTimeCapture === true &&
|
|
56380
|
+
abandonAfterMs !== undefined &&
|
|
56381
|
+
Number.isSafeInteger(abandonAfterMs) &&
|
|
56382
|
+
abandonAfterMs > 0
|
|
56383
|
+
? await scopedDb.execute<{ abandoned: boolean }>(sql`
|
|
56384
|
+
select coalesce(bool_and(coalesce(
|
|
56385
|
+
admission.actor_kind = 'turn'
|
|
56386
|
+
and admission.actor_id = attempt.id
|
|
56387
|
+
and admission.operation in ('lazyOwnedSandboxSetup', 'eagerOwnedSandboxSetup')
|
|
56388
|
+
and admission.route_kind = 'home'
|
|
56389
|
+
and admission.route_target_id is null
|
|
56390
|
+
and attempt.state = 'closed'
|
|
56391
|
+
and attempt.closed_at
|
|
56392
|
+
<= now() - (${abandonAfterMs}::bigint * interval '1 millisecond')
|
|
56393
|
+
and turn.status = 'recovering'
|
|
56394
|
+
and turn.active_attempt_id is null
|
|
56395
|
+
and turn.execution_generation = attempt.execution_generation
|
|
56396
|
+
and turn.metadata -> 'sandboxSetupOutcomeUnknown' @> jsonb_build_object(
|
|
56397
|
+
'version', 1, 'turnId', turn.id::text, 'attemptId', attempt.id::text,
|
|
56398
|
+
'reason', 'sandbox_command_start_outcome_unknown')
|
|
56399
|
+
and not exists (select 1 from sandbox_retained_processes process
|
|
56400
|
+
where process.workspace_id = attempt.workspace_id
|
|
56401
|
+
and process.session_id = attempt.session_id
|
|
56402
|
+
and process.owner_attempt_id = attempt.id
|
|
56403
|
+
and process.state = 'active'),
|
|
56404
|
+
false)), false) as abandoned
|
|
56405
|
+
from sandbox_workspace_mutation_admissions admission
|
|
56406
|
+
left join session_turn_attempts attempt
|
|
56407
|
+
on attempt.account_id = admission.account_id
|
|
56408
|
+
and attempt.workspace_id = admission.workspace_id
|
|
56409
|
+
and attempt.session_id = admission.session_id
|
|
56410
|
+
and attempt.turn_id = admission.turn_id
|
|
56411
|
+
and attempt.id = admission.attempt_id
|
|
56412
|
+
and attempt.execution_generation = admission.execution_generation
|
|
56413
|
+
left join session_turns turn
|
|
56414
|
+
on turn.workspace_id = attempt.workspace_id
|
|
56415
|
+
and turn.session_id = attempt.session_id
|
|
56416
|
+
and turn.id = attempt.turn_id
|
|
56417
|
+
where admission.id = any(${`{${unsettled
|
|
56418
|
+
.map((admission: { id: string }) => admission.id)
|
|
56419
|
+
.join(",")}}`}::uuid[])
|
|
56420
|
+
`)
|
|
56421
|
+
: [];
|
|
56422
|
+
if (deadline?.mandatory !== true && abandoned?.abandoned !== true) {
|
|
56359
56423
|
return { status: "mutation_in_progress" as const };
|
|
56360
56424
|
}
|
|
56361
|
-
// Mandatory
|
|
56362
|
-
//
|
|
56363
|
-
//
|
|
56364
|
-
//
|
|
56425
|
+
// Mandatory save: no holder is left on the box (checked above), so
|
|
56426
|
+
// these requests cannot finish into a capture in time. Record exactly
|
|
56427
|
+
// them; publication excludes them, and the cold commit settles them
|
|
56428
|
+
// only after the box is terminated.
|
|
56365
56429
|
await scopedDb.execute(sql`
|
|
56366
56430
|
update sandbox_leases set deadline_forced_admission_ids = ${`{${[
|
|
56367
56431
|
...forcedAdmissionIds,
|