@opengeni/db 1.1.0 → 1.2.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/dist/canonical-human-identities.js +3 -3
- package/dist/capability-reconciliation.js +2 -2
- package/dist/{chunk-J4WFOVBV.js → chunk-3GAR5UN3.js} +2 -2
- package/dist/{chunk-Z5HJXH3X.js → chunk-3TGYFFZR.js} +2 -2
- package/dist/{chunk-GLA5YCQS.js → chunk-6EIEGHPK.js} +4 -4
- package/dist/{chunk-BLUQG54M.js → chunk-CONWNUSL.js} +2 -2
- package/dist/{chunk-EKXCF3FH.js → chunk-CYP72JTQ.js} +11 -4
- package/dist/{chunk-EKXCF3FH.js.map → chunk-CYP72JTQ.js.map} +1 -1
- package/dist/{chunk-VOB6TXSJ.js → chunk-FWQNHNOU.js} +39 -2
- package/dist/chunk-FWQNHNOU.js.map +1 -0
- package/dist/{chunk-7Z7ALFRE.js → chunk-GDN5TSZP.js} +2 -2
- package/dist/{chunk-M3SWH4MU.js → chunk-JICUP7ZR.js} +2 -2
- package/dist/{chunk-KJVOBLFC.js → chunk-JLVWBIEM.js} +3 -3
- package/dist/{chunk-BZV5ZDRY.js → chunk-LXPFNG4L.js} +2 -2
- package/dist/{chunk-SKYRM7R7.js → chunk-O2Y5E5M3.js} +68 -32
- package/dist/chunk-O2Y5E5M3.js.map +1 -0
- package/dist/{chunk-M2YGXCCQ.js → chunk-QFJNEXFL.js} +2 -2
- package/dist/{chunk-CIGHXFPD.js → chunk-UCIMHNII.js} +101 -21
- package/dist/chunk-UCIMHNII.js.map +1 -0
- package/dist/{chunk-ODKYBA3C.js → chunk-VDPCIFKL.js} +5 -5
- package/dist/{chunk-T5NHDEZW.js → chunk-YOYILDLY.js} +26 -1
- package/dist/chunk-YOYILDLY.js.map +1 -0
- package/dist/connect-attempts.js +3 -3
- package/dist/credit-balances.d.ts +18 -0
- package/dist/credit-promotion-policy.d.ts +10 -0
- package/dist/editable-artifact-durable-export.js +2 -2
- package/dist/editable-artifacts.js +3 -3
- package/dist/index.d.ts +21 -5
- package/dist/index.js +2369 -2247
- package/dist/index.js.map +1 -1
- package/dist/managed-auth-session-sets.js +3 -3
- package/dist/managed-user-setup.d.ts +1 -0
- package/dist/mcp-operations.js +2 -2
- package/dist/organization-integration-policy.js +3 -3
- package/dist/organization-membership-lifecycle.d.ts +1 -0
- package/dist/persistence-errors.d.ts +9 -6
- package/dist/provision-roles.js +1 -1
- package/dist/retained-provider-commands.js +6 -6
- package/dist/runtime-posture.d.ts +4 -4
- package/dist/schema.d.ts +121 -2
- package/dist/schema.js +3 -1
- package/dist/session-background-commands.js +4 -4
- package/dist/session-command-output.js +5 -5
- package/dist/session-event-slices.js +3 -3
- package/dist/session-mcp-credential-rotation.js +5 -5
- package/dist/session-tenancy.js +3 -3
- package/dist/video-generation.js +3 -3
- package/dist/workspace-tool-defaults.js +4 -4
- package/drizzle/0613_model_scoped_promotional_credits.sql +238 -0
- package/drizzle/0614_quiescence_command_containment.sql +7 -0
- package/drizzle/0615_credit_promotion_policy_validation.sql +63 -0
- package/package.json +7 -7
- package/src/credit-balances.ts +109 -0
- package/src/credit-promotion-policy.ts +12 -0
- package/src/database.ts +81 -42
- package/src/index.ts +201 -60
- package/src/managed-user-setup.ts +8 -0
- package/src/organization-membership-lifecycle.ts +8 -0
- package/src/persistence-errors.ts +17 -6
- package/src/provision-roles.ts +9 -0
- package/src/runtime-posture.ts +22 -0
- package/src/schema.ts +40 -1
- package/src/video-generation.ts +2 -7
- package/dist/chunk-CIGHXFPD.js.map +0 -1
- package/dist/chunk-SKYRM7R7.js.map +0 -1
- package/dist/chunk-T5NHDEZW.js.map +0 -1
- package/dist/chunk-VOB6TXSJ.js.map +0 -1
- /package/dist/{chunk-J4WFOVBV.js.map → chunk-3GAR5UN3.js.map} +0 -0
- /package/dist/{chunk-Z5HJXH3X.js.map → chunk-3TGYFFZR.js.map} +0 -0
- /package/dist/{chunk-GLA5YCQS.js.map → chunk-6EIEGHPK.js.map} +0 -0
- /package/dist/{chunk-BLUQG54M.js.map → chunk-CONWNUSL.js.map} +0 -0
- /package/dist/{chunk-7Z7ALFRE.js.map → chunk-GDN5TSZP.js.map} +0 -0
- /package/dist/{chunk-M3SWH4MU.js.map → chunk-JICUP7ZR.js.map} +0 -0
- /package/dist/{chunk-KJVOBLFC.js.map → chunk-JLVWBIEM.js.map} +0 -0
- /package/dist/{chunk-BZV5ZDRY.js.map → chunk-LXPFNG4L.js.map} +0 -0
- /package/dist/{chunk-M2YGXCCQ.js.map → chunk-QFJNEXFL.js.map} +0 -0
- /package/dist/{chunk-ODKYBA3C.js.map → chunk-VDPCIFKL.js.map} +0 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
-- deployment-mode: rolling
|
|
2
|
+
-- Deploy every reader and debit writer before configuring scoped offers.
|
|
3
|
+
-- Existing grants keep NULL eligibility and remain unrestricted.
|
|
4
|
+
SET LOCAL lock_timeout = '5s';
|
|
5
|
+
SET LOCAL statement_timeout = '10min';
|
|
6
|
+
|
|
7
|
+
ALTER TABLE credit_ledger_entries ADD COLUMN eligible_model_ids text[];
|
|
8
|
+
ALTER TABLE credit_ledger_entries ADD CONSTRAINT credit_ledger_scope_valid CHECK (
|
|
9
|
+
eligible_model_ids IS NULL OR (type = 'grant' AND amount_micros > 0
|
|
10
|
+
AND cardinality(eligible_model_ids) BETWEEN 1 AND 40
|
|
11
|
+
AND array_position(eligible_model_ids, NULL) IS NULL
|
|
12
|
+
AND array_position(eligible_model_ids, '') IS NULL)
|
|
13
|
+
);
|
|
14
|
+
CREATE UNIQUE INDEX credit_ledger_entries_id_account_idx ON credit_ledger_entries (id, account_id);
|
|
15
|
+
CREATE INDEX credit_ledger_scoped_grants_idx ON credit_ledger_entries (account_id, created_at, id)
|
|
16
|
+
WHERE eligible_model_ids IS NOT NULL;
|
|
17
|
+
CREATE FUNCTION opengeni_private.preserve_credit_grant_scope() RETURNS trigger
|
|
18
|
+
LANGUAGE plpgsql SET search_path = pg_catalog AS $body$
|
|
19
|
+
BEGIN
|
|
20
|
+
IF NEW.eligible_model_ids IS DISTINCT FROM OLD.eligible_model_ids
|
|
21
|
+
OR (OLD.eligible_model_ids IS NOT NULL AND (
|
|
22
|
+
NEW.amount_micros IS DISTINCT FROM OLD.amount_micros
|
|
23
|
+
OR NEW.account_id IS DISTINCT FROM OLD.account_id
|
|
24
|
+
OR NEW.metadata->>'creditOfferLabel' IS DISTINCT FROM OLD.metadata->>'creditOfferLabel'
|
|
25
|
+
OR NEW.metadata->>'creditOfferId' IS DISTINCT FROM OLD.metadata->>'creditOfferId'
|
|
26
|
+
OR NEW.source_type IS DISTINCT FROM OLD.source_type
|
|
27
|
+
)) THEN
|
|
28
|
+
RAISE EXCEPTION 'Issued promotional credit terms are immutable' USING ERRCODE = '23514';
|
|
29
|
+
END IF;
|
|
30
|
+
RETURN NEW;
|
|
31
|
+
END $body$;
|
|
32
|
+
REVOKE ALL ON FUNCTION opengeni_private.preserve_credit_grant_scope() FROM PUBLIC;
|
|
33
|
+
CREATE TRIGGER preserve_credit_grant_scope BEFORE UPDATE ON credit_ledger_entries
|
|
34
|
+
FOR EACH ROW EXECUTE FUNCTION opengeni_private.preserve_credit_grant_scope();
|
|
35
|
+
|
|
36
|
+
CREATE TABLE credit_debit_allocations (
|
|
37
|
+
account_id uuid NOT NULL,
|
|
38
|
+
debit_entry_id uuid NOT NULL,
|
|
39
|
+
grant_entry_id uuid NOT NULL,
|
|
40
|
+
amount_micros bigint NOT NULL CONSTRAINT credit_debit_allocations_positive CHECK (amount_micros > 0),
|
|
41
|
+
PRIMARY KEY (debit_entry_id, grant_entry_id),
|
|
42
|
+
FOREIGN KEY (debit_entry_id, account_id) REFERENCES credit_ledger_entries (id, account_id) ON DELETE CASCADE,
|
|
43
|
+
FOREIGN KEY (grant_entry_id, account_id) REFERENCES credit_ledger_entries (id, account_id) ON DELETE CASCADE
|
|
44
|
+
);
|
|
45
|
+
CREATE INDEX credit_debit_allocations_grant_idx ON credit_debit_allocations (account_id, grant_entry_id);
|
|
46
|
+
ALTER TABLE credit_debit_allocations ENABLE ROW LEVEL SECURITY;
|
|
47
|
+
ALTER TABLE credit_debit_allocations FORCE ROW LEVEL SECURITY;
|
|
48
|
+
CREATE POLICY credit_debit_allocations_account_isolation ON credit_debit_allocations
|
|
49
|
+
USING (opengeni_private.account_rls_visible(account_id))
|
|
50
|
+
WITH CHECK (opengeni_private.account_rls_visible(account_id));
|
|
51
|
+
REVOKE ALL ON credit_debit_allocations FROM PUBLIC;
|
|
52
|
+
DO $grants$ BEGIN
|
|
53
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
|
|
54
|
+
GRANT SELECT, INSERT ON credit_debit_allocations TO opengeni_app;
|
|
55
|
+
END IF;
|
|
56
|
+
END $grants$;
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
-- Runtime policy: one deployment-wide default, with signup/coupon overrides.
|
|
60
|
+
-- Revisions stay available so an in-flight call can settle under its admitted policy.
|
|
61
|
+
CREATE TABLE opengeni_private.credit_promotion_policy_revisions (
|
|
62
|
+
revision bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
63
|
+
policy jsonb NOT NULL,
|
|
64
|
+
operator text NOT NULL CHECK (operator = btrim(operator) AND length(operator) BETWEEN 1 AND 200),
|
|
65
|
+
reason text NOT NULL CHECK (reason = btrim(reason) AND length(reason) BETWEEN 6 AND 1000),
|
|
66
|
+
database_role text NOT NULL DEFAULT session_user,
|
|
67
|
+
changed_at timestamptz NOT NULL DEFAULT clock_timestamp()
|
|
68
|
+
);
|
|
69
|
+
REVOKE ALL ON opengeni_private.credit_promotion_policy_revisions FROM PUBLIC;
|
|
70
|
+
CREATE TRIGGER credit_promotion_policy_revisions_immutable
|
|
71
|
+
BEFORE UPDATE OR DELETE ON opengeni_private.credit_promotion_policy_revisions
|
|
72
|
+
FOR EACH ROW EXECUTE FUNCTION reject_verified_signup_trial_switch_revision_mutation();
|
|
73
|
+
CREATE TRIGGER credit_promotion_policy_revisions_no_truncate
|
|
74
|
+
BEFORE TRUNCATE ON opengeni_private.credit_promotion_policy_revisions
|
|
75
|
+
FOR EACH STATEMENT EXECUTE FUNCTION reject_verified_signup_trial_switch_revision_mutation();
|
|
76
|
+
|
|
77
|
+
CREATE FUNCTION set_credit_promotion_policy(p_policy jsonb, p_operator text, p_reason text)
|
|
78
|
+
RETURNS bigint LANGUAGE plpgsql SET search_path = pg_catalog AS $body$
|
|
79
|
+
DECLARE
|
|
80
|
+
model_list jsonb;
|
|
81
|
+
offer jsonb;
|
|
82
|
+
written_revision bigint;
|
|
83
|
+
BEGIN
|
|
84
|
+
IF p_policy IS NULL OR jsonb_typeof(p_policy) <> 'object'
|
|
85
|
+
OR NOT p_policy ? 'defaultModelIds'
|
|
86
|
+
OR p_policy - ARRAY['defaultModelIds', 'signupModelIds', 'offers'] <> '{}'::jsonb
|
|
87
|
+
OR (p_policy ? 'offers' AND jsonb_typeof(p_policy->'offers') <> 'object') THEN
|
|
88
|
+
RAISE EXCEPTION 'Policy requires defaultModelIds and optional signupModelIds/offers' USING ERRCODE = '22023';
|
|
89
|
+
END IF;
|
|
90
|
+
FOR offer IN SELECT value FROM jsonb_each(coalesce(p_policy->'offers', '{}'::jsonb)) LOOP
|
|
91
|
+
IF jsonb_typeof(offer) <> 'object'
|
|
92
|
+
OR offer - ARRAY['label', 'eligibleModelIds'] <> '{}'::jsonb
|
|
93
|
+
OR jsonb_typeof(offer->'label') IS DISTINCT FROM 'string'
|
|
94
|
+
OR length(btrim(offer->>'label')) NOT BETWEEN 1 AND 120 THEN
|
|
95
|
+
RAISE EXCEPTION 'Invalid credit offer' USING ERRCODE = '22023';
|
|
96
|
+
END IF;
|
|
97
|
+
END LOOP;
|
|
98
|
+
FOR model_list IN
|
|
99
|
+
SELECT p_policy->'defaultModelIds'
|
|
100
|
+
UNION ALL SELECT p_policy->'signupModelIds' WHERE p_policy ? 'signupModelIds'
|
|
101
|
+
UNION ALL SELECT value->'eligibleModelIds'
|
|
102
|
+
FROM jsonb_each(coalesce(p_policy->'offers', '{}'::jsonb)) WHERE value ? 'eligibleModelIds'
|
|
103
|
+
LOOP
|
|
104
|
+
IF jsonb_typeof(model_list) IS DISTINCT FROM 'array' THEN
|
|
105
|
+
RAISE EXCEPTION 'Model list must be an array' USING ERRCODE = '22023';
|
|
106
|
+
END IF;
|
|
107
|
+
IF jsonb_array_length(model_list) NOT BETWEEN 1 AND 40 OR EXISTS (
|
|
108
|
+
SELECT 1 FROM jsonb_array_elements(model_list) id
|
|
109
|
+
WHERE jsonb_typeof(id) <> 'string' OR length(btrim(id #>> '{}')) NOT BETWEEN 1 AND 200
|
|
110
|
+
OR id #>> '{}' <> btrim(id #>> '{}')
|
|
111
|
+
) THEN
|
|
112
|
+
RAISE EXCEPTION 'Model list requires 1-40 canonical model IDs' USING ERRCODE = '22023';
|
|
113
|
+
END IF;
|
|
114
|
+
END LOOP;
|
|
115
|
+
-- Serializes revision order across simultaneous operator updates.
|
|
116
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('credit-promotion-policy', 0));
|
|
117
|
+
INSERT INTO opengeni_private.credit_promotion_policy_revisions (policy, operator, reason)
|
|
118
|
+
VALUES (p_policy, p_operator, p_reason) RETURNING revision INTO written_revision;
|
|
119
|
+
RETURN written_revision;
|
|
120
|
+
END $body$;
|
|
121
|
+
REVOKE ALL ON FUNCTION set_credit_promotion_policy(jsonb, text, text) FROM PUBLIC;
|
|
122
|
+
DO $policy_acl$
|
|
123
|
+
DECLARE grantee_name text;
|
|
124
|
+
BEGIN
|
|
125
|
+
FOR grantee_name IN
|
|
126
|
+
SELECT DISTINCT r.rolname FROM pg_class c
|
|
127
|
+
CROSS JOIN LATERAL aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) a
|
|
128
|
+
JOIN pg_roles r ON r.oid = a.grantee
|
|
129
|
+
WHERE c.oid = 'opengeni_private.credit_promotion_policy_revisions'::regclass AND a.grantee <> c.relowner
|
|
130
|
+
LOOP
|
|
131
|
+
EXECUTE format('REVOKE ALL ON opengeni_private.credit_promotion_policy_revisions FROM %I', grantee_name);
|
|
132
|
+
END LOOP;
|
|
133
|
+
FOR grantee_name IN
|
|
134
|
+
SELECT DISTINCT r.rolname FROM pg_proc p
|
|
135
|
+
CROSS JOIN LATERAL aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) a
|
|
136
|
+
JOIN pg_roles r ON r.oid = a.grantee
|
|
137
|
+
WHERE p.oid = 'set_credit_promotion_policy(jsonb,text,text)'::regprocedure AND a.grantee <> p.proowner
|
|
138
|
+
LOOP
|
|
139
|
+
EXECUTE format('REVOKE ALL ON FUNCTION set_credit_promotion_policy(jsonb,text,text) FROM %I', grantee_name);
|
|
140
|
+
END LOOP;
|
|
141
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'opengeni_app') THEN
|
|
142
|
+
GRANT SELECT ON opengeni_private.credit_promotion_policy_revisions TO opengeni_app;
|
|
143
|
+
END IF;
|
|
144
|
+
END $policy_acl$;
|
|
145
|
+
|
|
146
|
+
CREATE OR REPLACE FUNCTION opengeni_private.grant_verified_signup_trial_credit()
|
|
147
|
+
RETURNS trigger
|
|
148
|
+
LANGUAGE plpgsql
|
|
149
|
+
SECURITY DEFINER
|
|
150
|
+
SET search_path FROM CURRENT
|
|
151
|
+
AS $body$
|
|
152
|
+
DECLARE
|
|
153
|
+
verified_owner boolean;
|
|
154
|
+
account_owner boolean;
|
|
155
|
+
runtime_enabled boolean;
|
|
156
|
+
inserted_count integer;
|
|
157
|
+
model_ids jsonb;
|
|
158
|
+
BEGIN
|
|
159
|
+
IF pg_catalog.current_setting('opengeni.verified_signup_trial_enabled', true)
|
|
160
|
+
IS DISTINCT FROM 'on' THEN
|
|
161
|
+
RETURN NEW;
|
|
162
|
+
END IF;
|
|
163
|
+
|
|
164
|
+
-- Shared against the setter's exclusive lock; a missing revision fails closed.
|
|
165
|
+
PERFORM pg_catalog.pg_advisory_xact_lock_shared(
|
|
166
|
+
pg_catalog.hashtextextended('verified-signup-trial-switch', 0)
|
|
167
|
+
);
|
|
168
|
+
SELECT revision.grants_enabled
|
|
169
|
+
INTO runtime_enabled
|
|
170
|
+
FROM opengeni_private.verified_signup_trial_switch_revisions revision
|
|
171
|
+
ORDER BY revision.revision DESC
|
|
172
|
+
LIMIT 1;
|
|
173
|
+
IF runtime_enabled IS DISTINCT FROM TRUE THEN
|
|
174
|
+
RETURN NEW;
|
|
175
|
+
END IF;
|
|
176
|
+
|
|
177
|
+
-- This is a defense in depth check: the sole receipt writer already checks
|
|
178
|
+
-- the canonical verified human and the absence of prior memberships.
|
|
179
|
+
SELECT candidate.email_verified IS TRUE
|
|
180
|
+
INTO verified_owner
|
|
181
|
+
FROM auth_users candidate
|
|
182
|
+
WHERE candidate.id = NEW.auth_user_id;
|
|
183
|
+
SELECT account.external_source = 'better-auth:user'
|
|
184
|
+
AND account.external_id = NEW.auth_user_id
|
|
185
|
+
INTO account_owner
|
|
186
|
+
FROM managed_accounts account
|
|
187
|
+
WHERE account.id = NEW.account_id;
|
|
188
|
+
IF verified_owner IS DISTINCT FROM TRUE
|
|
189
|
+
OR account_owner IS DISTINCT FROM TRUE
|
|
190
|
+
OR opengeni_private.current_subject_id() IS DISTINCT FROM 'user:' || NEW.auth_user_id
|
|
191
|
+
OR opengeni_private.current_account_id() IS DISTINCT FROM NEW.account_id
|
|
192
|
+
OR NEW.result ->> 'organizationId' IS DISTINCT FROM NEW.account_id::text
|
|
193
|
+
THEN
|
|
194
|
+
RAISE EXCEPTION 'verified signup trial requires an exact new owner'
|
|
195
|
+
USING ERRCODE = '42501';
|
|
196
|
+
END IF;
|
|
197
|
+
|
|
198
|
+
SELECT coalesce(policy->'signupModelIds', policy->'defaultModelIds') INTO model_ids
|
|
199
|
+
FROM opengeni_private.credit_promotion_policy_revisions ORDER BY revision DESC LIMIT 1;
|
|
200
|
+
model_ids := coalesce(model_ids, nullif(pg_catalog.current_setting('opengeni.signup_credit_model_ids', true), '')::jsonb);
|
|
201
|
+
INSERT INTO credit_ledger_entries (
|
|
202
|
+
account_id, workspace_id, type, amount_micros, source_type, source_id,
|
|
203
|
+
idempotency_key, metadata, eligible_model_ids
|
|
204
|
+
) VALUES (
|
|
205
|
+
NEW.account_id, NULL, 'grant', 10000000, 'verified_signup_trial',
|
|
206
|
+
NEW.auth_user_id, 'verified-signup-trial:v1:' || NEW.auth_user_id,
|
|
207
|
+
pg_catalog.jsonb_build_object('campaign', 'verified_signup_trial_v1',
|
|
208
|
+
'creditOfferLabel', 'Signup credits'),
|
|
209
|
+
CASE WHEN model_ids IS NULL THEN NULL
|
|
210
|
+
ELSE ARRAY(SELECT pg_catalog.jsonb_array_elements_text(model_ids)) END
|
|
211
|
+
) ON CONFLICT (idempotency_key) DO NOTHING;
|
|
212
|
+
GET DIAGNOSTICS inserted_count = ROW_COUNT;
|
|
213
|
+
IF inserted_count <> 1 THEN
|
|
214
|
+
RAISE EXCEPTION 'verified signup trial credit idempotency collision'
|
|
215
|
+
USING ERRCODE = '23505';
|
|
216
|
+
END IF;
|
|
217
|
+
RETURN NEW;
|
|
218
|
+
END
|
|
219
|
+
$body$;
|
|
220
|
+
|
|
221
|
+
-- CREATE OR REPLACE resets SET search_path FROM CURRENT: re-pin both definers.
|
|
222
|
+
DO $posture$
|
|
223
|
+
DECLARE
|
|
224
|
+
data_schema text := pg_catalog.current_schema();
|
|
225
|
+
BEGIN
|
|
226
|
+
EXECUTE pg_catalog.format(
|
|
227
|
+
'ALTER FUNCTION opengeni_private.grant_verified_signup_trial_credit() '
|
|
228
|
+
|| 'SET search_path = pg_catalog, %I, opengeni_private, pg_temp',
|
|
229
|
+
data_schema
|
|
230
|
+
);
|
|
231
|
+
EXECUTE pg_catalog.format(
|
|
232
|
+
'ALTER FUNCTION %I.set_verified_signup_trial_credits_enabled(boolean, text, text) '
|
|
233
|
+
|| 'SET search_path = pg_catalog, %I, pg_temp',
|
|
234
|
+
data_schema,
|
|
235
|
+
data_schema
|
|
236
|
+
);
|
|
237
|
+
END
|
|
238
|
+
$posture$;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- deployment-mode: rolling
|
|
2
|
+
-- Additive reason: existing capture/termination fences remain unchanged.
|
|
3
|
+
ALTER TABLE sandbox_leases
|
|
4
|
+
DROP CONSTRAINT sandbox_leases_command_containment_reason_check,
|
|
5
|
+
ADD CONSTRAINT sandbox_leases_command_containment_reason_check
|
|
6
|
+
CHECK (command_containment_reason IS NULL OR command_containment_reason IN
|
|
7
|
+
('idle_containment', 'provider_deadline_containment', 'quiescence_containment'));
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
-- deployment-mode: rolling
|
|
2
|
+
SET LOCAL lock_timeout = '5s';
|
|
3
|
+
SET LOCAL statement_timeout = '10min';
|
|
4
|
+
|
|
5
|
+
-- Keep owner SQL updates readable by CreditPromotionPolicy. Replacing the
|
|
6
|
+
-- function preserves its owner-only ACL and the immutable revision history.
|
|
7
|
+
CREATE OR REPLACE FUNCTION set_credit_promotion_policy(p_policy jsonb, p_operator text, p_reason text)
|
|
8
|
+
RETURNS bigint LANGUAGE plpgsql SET search_path = pg_catalog AS $body$
|
|
9
|
+
DECLARE
|
|
10
|
+
-- ECMAScript String.trim whitespace; PostgreSQL btrim defaults to ASCII space.
|
|
11
|
+
trim_chars CONSTANT text := U&'\0009\000A\000B\000C\000D\0020\00A0\1680\2000\2001\2002\2003\2004\2005\2006\2007\2008\2009\200A\2028\2029\202F\205F\3000\FEFF';
|
|
12
|
+
model_list jsonb;
|
|
13
|
+
model_id text;
|
|
14
|
+
offer_id text;
|
|
15
|
+
offer jsonb;
|
|
16
|
+
offer_label text;
|
|
17
|
+
written_revision bigint;
|
|
18
|
+
BEGIN
|
|
19
|
+
IF p_policy IS NULL OR jsonb_typeof(p_policy) <> 'object'
|
|
20
|
+
OR NOT p_policy ? 'defaultModelIds'
|
|
21
|
+
OR p_policy - ARRAY['defaultModelIds', 'signupModelIds', 'offers'] <> '{}'::jsonb
|
|
22
|
+
OR (p_policy ? 'offers' AND jsonb_typeof(p_policy->'offers') <> 'object') THEN
|
|
23
|
+
RAISE EXCEPTION 'Policy requires defaultModelIds and optional signupModelIds/offers' USING ERRCODE = '22023';
|
|
24
|
+
END IF;
|
|
25
|
+
FOR offer_id, offer IN SELECT key, value FROM jsonb_each(coalesce(p_policy->'offers', '{}'::jsonb)) LOOP
|
|
26
|
+
IF offer_id = '' OR jsonb_typeof(offer) <> 'object'
|
|
27
|
+
OR offer - ARRAY['label', 'eligibleModelIds'] <> '{}'::jsonb
|
|
28
|
+
OR jsonb_typeof(offer->'label') IS DISTINCT FROM 'string' THEN
|
|
29
|
+
RAISE EXCEPTION 'Invalid credit offer' USING ERRCODE = '22023';
|
|
30
|
+
END IF;
|
|
31
|
+
offer_label := btrim(offer->>'label', trim_chars);
|
|
32
|
+
-- JavaScript string limits count UTF-16 units: supplementary characters use two.
|
|
33
|
+
IF length(regexp_replace(offer_label, U&'[\+010000-\+10FFFF]', 'xx', 'g')) NOT BETWEEN 1 AND 120 THEN
|
|
34
|
+
RAISE EXCEPTION 'Invalid credit offer label' USING ERRCODE = '22023';
|
|
35
|
+
END IF;
|
|
36
|
+
END LOOP;
|
|
37
|
+
FOR model_list IN
|
|
38
|
+
SELECT p_policy->'defaultModelIds'
|
|
39
|
+
UNION ALL SELECT p_policy->'signupModelIds' WHERE p_policy ? 'signupModelIds'
|
|
40
|
+
UNION ALL SELECT value->'eligibleModelIds'
|
|
41
|
+
FROM jsonb_each(coalesce(p_policy->'offers', '{}'::jsonb)) WHERE value ? 'eligibleModelIds'
|
|
42
|
+
LOOP
|
|
43
|
+
IF jsonb_typeof(model_list) IS DISTINCT FROM 'array' THEN
|
|
44
|
+
RAISE EXCEPTION 'Model list must be an array' USING ERRCODE = '22023';
|
|
45
|
+
END IF;
|
|
46
|
+
IF jsonb_array_length(model_list) NOT BETWEEN 1 AND 40 OR EXISTS (
|
|
47
|
+
SELECT 1 FROM jsonb_array_elements(model_list) id WHERE jsonb_typeof(id) <> 'string'
|
|
48
|
+
) THEN
|
|
49
|
+
RAISE EXCEPTION 'Model list requires 1-40 canonical model IDs' USING ERRCODE = '22023';
|
|
50
|
+
END IF;
|
|
51
|
+
FOR model_id IN SELECT jsonb_array_elements_text(model_list) LOOP
|
|
52
|
+
IF model_id <> btrim(model_id, trim_chars)
|
|
53
|
+
OR length(regexp_replace(model_id, U&'[\+010000-\+10FFFF]', 'xx', 'g')) NOT BETWEEN 1 AND 200 THEN
|
|
54
|
+
RAISE EXCEPTION 'Model list requires 1-40 canonical model IDs' USING ERRCODE = '22023';
|
|
55
|
+
END IF;
|
|
56
|
+
END LOOP;
|
|
57
|
+
END LOOP;
|
|
58
|
+
-- Serializes revision order across simultaneous operator updates.
|
|
59
|
+
PERFORM pg_advisory_xact_lock(hashtextextended('credit-promotion-policy', 0));
|
|
60
|
+
INSERT INTO opengeni_private.credit_promotion_policy_revisions (policy, operator, reason)
|
|
61
|
+
VALUES (p_policy, p_operator, p_reason) RETURNING revision INTO written_revision;
|
|
62
|
+
RETURN written_revision;
|
|
63
|
+
END $body$;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/db",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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": {
|
|
@@ -115,12 +115,12 @@
|
|
|
115
115
|
"prepublishOnly": "bash ../../scripts/prepublish-guard"
|
|
116
116
|
},
|
|
117
117
|
"dependencies": {
|
|
118
|
-
"@opengeni/codemode": "1.
|
|
119
|
-
"@opengeni/codex": "1.
|
|
120
|
-
"@opengeni/config": "1.
|
|
121
|
-
"@opengeni/contracts": "1.
|
|
122
|
-
"@opengeni/network": "1.
|
|
123
|
-
"@opengeni/observability": "1.
|
|
118
|
+
"@opengeni/codemode": "1.2.0",
|
|
119
|
+
"@opengeni/codex": "1.2.0",
|
|
120
|
+
"@opengeni/config": "1.2.0",
|
|
121
|
+
"@opengeni/contracts": "1.2.0",
|
|
122
|
+
"@opengeni/network": "1.2.0",
|
|
123
|
+
"@opengeni/observability": "1.2.0",
|
|
124
124
|
"drizzle-orm": "^0.45.2",
|
|
125
125
|
"postgres": "^3.4.7"
|
|
126
126
|
},
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { BillingBalance, PromotionalCreditBalance } from "@opengeni/contracts";
|
|
2
|
+
import { sql } from "drizzle-orm";
|
|
3
|
+
import type { Database } from "./database";
|
|
4
|
+
import { rawRows, withAccountRls } from "./database";
|
|
5
|
+
import * as schema from "./schema";
|
|
6
|
+
|
|
7
|
+
/** Read totals and grant remainders from one database snapshot. */
|
|
8
|
+
type CreditBalanceSnapshot = BillingBalance & { creditPolicyRevision?: number };
|
|
9
|
+
|
|
10
|
+
export async function getBillingBalance(
|
|
11
|
+
db: Database,
|
|
12
|
+
accountId: string,
|
|
13
|
+
policyRevision?: number,
|
|
14
|
+
): Promise<CreditBalanceSnapshot> {
|
|
15
|
+
return await withAccountRls(db, accountId, async (tx) => {
|
|
16
|
+
const [row] = await rawRows<{
|
|
17
|
+
balance: string;
|
|
18
|
+
grants: PromotionalCreditBalance[];
|
|
19
|
+
revision: number;
|
|
20
|
+
}>(
|
|
21
|
+
tx,
|
|
22
|
+
sql`
|
|
23
|
+
with current_policy as (
|
|
24
|
+
select revision, policy from opengeni_private.credit_promotion_policy_revisions
|
|
25
|
+
where (${policyRevision ?? null}::bigint is null or revision <= ${policyRevision ?? null}::bigint)
|
|
26
|
+
order by revision desc limit 1
|
|
27
|
+
)
|
|
28
|
+
select
|
|
29
|
+
coalesce((select revision from current_policy), 0)::int as revision,
|
|
30
|
+
(select coalesce(sum(amount_micros), 0)::text
|
|
31
|
+
from ${schema.creditLedgerEntries} where account_id = ${accountId}) as balance,
|
|
32
|
+
coalesce((select jsonb_agg(jsonb_build_object(
|
|
33
|
+
'grantId', g.id,
|
|
34
|
+
'label', coalesce(g.metadata->>'creditOfferLabel', 'Promotional credits'),
|
|
35
|
+
'eligibleModelIds', coalesce(
|
|
36
|
+
(select coalesce(
|
|
37
|
+
case when g.source_type = 'verified_signup_trial' then policy->'signupModelIds'
|
|
38
|
+
else policy->'offers'->(g.metadata->>'creditOfferId')->'eligibleModelIds' end,
|
|
39
|
+
policy->'defaultModelIds'
|
|
40
|
+
) from current_policy), to_jsonb(g.eligible_model_ids)),
|
|
41
|
+
'remainingMicros', g.amount_micros - coalesce(used.amount, 0)
|
|
42
|
+
) order by g.created_at, g.id)
|
|
43
|
+
from ${schema.creditLedgerEntries} g
|
|
44
|
+
left join (
|
|
45
|
+
select grant_entry_id, sum(amount_micros) as amount
|
|
46
|
+
from ${schema.creditDebitAllocations} where account_id = ${accountId}
|
|
47
|
+
group by grant_entry_id
|
|
48
|
+
) used on used.grant_entry_id = g.id
|
|
49
|
+
where g.account_id = ${accountId} and g.eligible_model_ids is not null
|
|
50
|
+
and g.amount_micros > coalesce(used.amount, 0)
|
|
51
|
+
), '[]'::jsonb) as grants
|
|
52
|
+
`,
|
|
53
|
+
);
|
|
54
|
+
const balanceMicros = Number(row?.balance ?? 0);
|
|
55
|
+
const promotionalCredits = row?.grants ?? [];
|
|
56
|
+
return {
|
|
57
|
+
accountId,
|
|
58
|
+
creditPolicyRevision: row?.revision ?? 0,
|
|
59
|
+
balanceMicros,
|
|
60
|
+
generalBalanceMicros:
|
|
61
|
+
balanceMicros - promotionalCredits.reduce((sum, grant) => sum + grant.remainingMicros, 0),
|
|
62
|
+
promotionalCredits,
|
|
63
|
+
currency: "usd",
|
|
64
|
+
updatedAt: new Date().toISOString(),
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Missing model means a non-model resource, which can only use general credits. */
|
|
70
|
+
export function spendableCreditMicros(balance: BillingBalance, modelId?: string): number {
|
|
71
|
+
return (
|
|
72
|
+
Math.max(0, balance.generalBalanceMicros ?? balance.balanceMicros) +
|
|
73
|
+
(balance.promotionalCredits ?? []).reduce(
|
|
74
|
+
(sum, grant) =>
|
|
75
|
+
sum + (modelId && grant.eligibleModelIds.includes(modelId) ? grant.remainingMicros : 0),
|
|
76
|
+
0,
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function getSpendableCreditBalance(
|
|
82
|
+
db: Database,
|
|
83
|
+
accountId: string,
|
|
84
|
+
modelId?: string,
|
|
85
|
+
policyRevision?: number,
|
|
86
|
+
): Promise<CreditBalanceSnapshot> {
|
|
87
|
+
const balance = await getBillingBalance(db, accountId, policyRevision);
|
|
88
|
+
return { ...balance, balanceMicros: spendableCreditMicros(balance, modelId) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function planCreditDebit(
|
|
92
|
+
balance: BillingBalance,
|
|
93
|
+
requestedMicros: number,
|
|
94
|
+
modelId?: string,
|
|
95
|
+
) {
|
|
96
|
+
if (!Number.isSafeInteger(requestedMicros) || requestedMicros <= 0) {
|
|
97
|
+
throw new Error("credit debit requires a positive, safe integer micro amount");
|
|
98
|
+
}
|
|
99
|
+
const debitedMicros = Math.min(requestedMicros, spendableCreditMicros(balance, modelId));
|
|
100
|
+
let remaining = debitedMicros;
|
|
101
|
+
const allocations: { grantEntryId: string; amountMicros: number }[] = [];
|
|
102
|
+
for (const grant of balance.promotionalCredits ?? []) {
|
|
103
|
+
if (!remaining || !modelId || !grant.eligibleModelIds.includes(modelId)) continue;
|
|
104
|
+
const amountMicros = Math.min(remaining, grant.remainingMicros);
|
|
105
|
+
allocations.push({ grantEntryId: grant.grantId, amountMicros });
|
|
106
|
+
remaining -= amountMicros;
|
|
107
|
+
}
|
|
108
|
+
return { debitedMicros, allocations };
|
|
109
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { CreditPromotionPolicy } from "@opengeni/config";
|
|
2
|
+
import { sql } from "drizzle-orm";
|
|
3
|
+
import { rawRows, type Database } from "./database";
|
|
4
|
+
|
|
5
|
+
/** Runtime policy supersedes deployment bootstrap configuration. No process cache. */
|
|
6
|
+
export async function readCreditPromotionPolicy(db: Database) {
|
|
7
|
+
const [row] = await rawRows<{ policy: unknown }>(
|
|
8
|
+
db,
|
|
9
|
+
sql`select policy from opengeni_private.credit_promotion_policy_revisions order by revision desc limit 1`,
|
|
10
|
+
);
|
|
11
|
+
return row ? CreditPromotionPolicy.parse(row.policy) : null;
|
|
12
|
+
}
|
package/src/database.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { drizzle } from "drizzle-orm/postgres-js";
|
|
|
6
6
|
import postgres from "postgres";
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
DatabaseTransactionError,
|
|
10
|
+
isDatabasePersistenceFailure,
|
|
9
11
|
runIdempotentPersistenceTransaction,
|
|
10
12
|
type IdempotentPersistenceTransactionOptions,
|
|
11
13
|
} from "./persistence-errors";
|
|
@@ -521,6 +523,39 @@ async function restoreRlsContextSettings(
|
|
|
521
523
|
}
|
|
522
524
|
}
|
|
523
525
|
|
|
526
|
+
/** Attribute only failures outside the application callback to our transaction
|
|
527
|
+
* driver. BEGIN/COMMIT/ROLLBACK can throw raw postgres.js errors, unlike ORM
|
|
528
|
+
* queries. This supplies provenance, never transaction retry permission. */
|
|
529
|
+
async function withDatabaseTransactionProvenance<T>(
|
|
530
|
+
db: Database,
|
|
531
|
+
fn: (db: Database) => Promise<T>,
|
|
532
|
+
transactionConfig?: PgTransactionConfig,
|
|
533
|
+
): Promise<T> {
|
|
534
|
+
let entered = false;
|
|
535
|
+
let callbackFailure: { error: unknown } | undefined;
|
|
536
|
+
try {
|
|
537
|
+
return await db.transaction(async (tx) => {
|
|
538
|
+
entered = true;
|
|
539
|
+
try {
|
|
540
|
+
return await fn(tx as unknown as Database);
|
|
541
|
+
} catch (error) {
|
|
542
|
+
callbackFailure = { error };
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
545
|
+
}, transactionConfig);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
// A provider/tool rejection propagated by a successful rollback is still
|
|
548
|
+
// the provider/tool's error, even if it has a connection-looking code.
|
|
549
|
+
if (callbackFailure && callbackFailure.error === error) throw error;
|
|
550
|
+
if (!isDatabasePersistenceFailure(error)) throw error;
|
|
551
|
+
throw new DatabaseTransactionError(
|
|
552
|
+
entered ? "settlement" : "admission",
|
|
553
|
+
error,
|
|
554
|
+
callbackFailure?.error,
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
524
559
|
export async function withRlsContext<T>(
|
|
525
560
|
db: Database,
|
|
526
561
|
context: RlsContext,
|
|
@@ -535,49 +570,53 @@ export async function withRlsContext<T>(
|
|
|
535
570
|
restoreParentScope ? "savepoint_admission" : "transaction_admission",
|
|
536
571
|
);
|
|
537
572
|
try {
|
|
538
|
-
return await
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
parentScope
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
await scoped
|
|
548
|
-
|
|
549
|
-
|
|
573
|
+
return await withDatabaseTransactionProvenance(
|
|
574
|
+
db,
|
|
575
|
+
async (tx) => {
|
|
576
|
+
admission("completed");
|
|
577
|
+
const setup = startDatabaseTiming("rls_setup");
|
|
578
|
+
const scoped = tx as unknown as Database;
|
|
579
|
+
let parentScope: RlsContextSettings | null;
|
|
580
|
+
try {
|
|
581
|
+
parentScope = restoreParentScope ? await readRlsContextSettings(scoped) : null;
|
|
582
|
+
await setRlsContext(scoped, context);
|
|
583
|
+
if (context.workspaceId && sessionTenancyFence === "shared") {
|
|
584
|
+
await scoped.execute(
|
|
585
|
+
sql`select pg_advisory_xact_lock_shared(hashtextextended(${`session-tenancy:${context.workspaceId}`}, 0))`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
// Defense-in-depth: read the LOCAL GUC back on THIS backend BEFORE running
|
|
589
|
+
// the scoped query. The set_config and this read share one db.transaction,
|
|
590
|
+
// which a transaction pooler pins to a single backend — so a mismatch here
|
|
591
|
+
// means the context was genuinely lost (a torn transaction / pooler backend
|
|
592
|
+
// swap), not normal operation. Without this guard such an event runs the
|
|
593
|
+
// scoped read with an empty account_id and returns zero RLS-visible rows,
|
|
594
|
+
// manufacturing a phantom "no active subscription" from a credential that is
|
|
595
|
+
// in fact active. Convert that silent false into a loud, root-cause-bearing
|
|
596
|
+
// error so the caller can retry rather than permanently mis-decide.
|
|
597
|
+
await assertRlsContextApplied(scoped, context);
|
|
598
|
+
setup("completed");
|
|
599
|
+
} catch (error) {
|
|
600
|
+
setup("failed");
|
|
601
|
+
throw error;
|
|
550
602
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
try {
|
|
569
|
-
value = await fn(scoped);
|
|
570
|
-
callback("completed");
|
|
571
|
-
} catch (error) {
|
|
572
|
-
callback("failed");
|
|
573
|
-
throw error;
|
|
574
|
-
}
|
|
575
|
-
// A nested transaction is a savepoint, and SET LOCAL survives successful
|
|
576
|
-
// savepoint release. Restore the parent tenant and actor proof together;
|
|
577
|
-
// writer/protocol capabilities intentionally remain transaction-wide.
|
|
578
|
-
if (parentScope) await restoreRlsContextSettings(scoped, parentScope);
|
|
579
|
-
return value;
|
|
580
|
-
}, transactionConfig);
|
|
603
|
+
const callback = startDatabaseTiming("scoped_callback");
|
|
604
|
+
let value: T;
|
|
605
|
+
try {
|
|
606
|
+
value = await fn(scoped);
|
|
607
|
+
callback("completed");
|
|
608
|
+
} catch (error) {
|
|
609
|
+
callback("failed");
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
// A nested transaction is a savepoint, and SET LOCAL survives successful
|
|
613
|
+
// savepoint release. Restore the parent tenant and actor proof together;
|
|
614
|
+
// writer/protocol capabilities intentionally remain transaction-wide.
|
|
615
|
+
if (parentScope) await restoreRlsContextSettings(scoped, parentScope);
|
|
616
|
+
return value;
|
|
617
|
+
},
|
|
618
|
+
transactionConfig,
|
|
619
|
+
);
|
|
581
620
|
} catch (error) {
|
|
582
621
|
// No-op after callback entry; only admission failures are counted here.
|
|
583
622
|
admission("failed");
|