@smithers-orchestrator/control-plane 0.20.4

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 William Cory
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@smithers-orchestrator/control-plane",
3
+ "version": "0.20.4",
4
+ "description": "Durable organization, project, billing, usage, secret-reference, and audit primitives for hosted Smithers control planes.",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./src/index.d.ts",
10
+ "import": "./src/index.js",
11
+ "default": "./src/index.js"
12
+ },
13
+ "./*": {
14
+ "types": "./src/index.d.ts",
15
+ "import": "./src/*.js",
16
+ "default": "./src/*.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "src/"
21
+ ],
22
+ "dependencies": {
23
+ "@smithers-orchestrator/errors": "0.20.4"
24
+ },
25
+ "devDependencies": {
26
+ "@types/bun": "latest",
27
+ "typescript": "~5.9.3"
28
+ },
29
+ "scripts": {
30
+ "test": "bun test tests",
31
+ "typecheck": "tsc -p tsconfig.json --noEmit",
32
+ "build": "tsup --dts-only"
33
+ }
34
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { Database } from 'bun:sqlite';
2
+
3
+ type ControlPlaneSqlite = Database | {
4
+ exec(sql: string): unknown;
5
+ query(sql: string): {
6
+ run(...params: unknown[]): unknown;
7
+ get(...params: unknown[]): Record<string, unknown> | null;
8
+ all(...params: unknown[]): Array<Record<string, unknown>>;
9
+ };
10
+ };
11
+
12
+ type ControlPlaneOrg = {
13
+ orgId: string;
14
+ slug: string;
15
+ name: string;
16
+ createdAtMs: number;
17
+ };
18
+
19
+ type ControlPlaneTeam = {
20
+ orgId: string;
21
+ teamId: string;
22
+ slug: string;
23
+ name: string;
24
+ createdAtMs: number;
25
+ };
26
+
27
+ type ControlPlaneProject = {
28
+ orgId: string;
29
+ projectId: string;
30
+ slug: string;
31
+ name: string;
32
+ metadata: Record<string, unknown>;
33
+ createdAtMs: number;
34
+ };
35
+
36
+ type ControlPlaneBillingAccount = {
37
+ orgId: string;
38
+ plan: string;
39
+ billingCustomerId: string | null;
40
+ status: string;
41
+ updatedAtMs: number;
42
+ };
43
+
44
+ type ControlPlaneIdentityProvider = {
45
+ orgId: string;
46
+ providerId: string;
47
+ type: string;
48
+ issuer: string;
49
+ ssoUrl: string | null;
50
+ certificateRef: string | null;
51
+ status: string;
52
+ metadata: Record<string, unknown>;
53
+ createdAtMs: number;
54
+ updatedAtMs: number;
55
+ };
56
+
57
+ type ControlPlaneUsageEvent = {
58
+ id: number;
59
+ orgId: string;
60
+ projectId: string | null;
61
+ runId: string | null;
62
+ metric: string;
63
+ quantity: number;
64
+ unit: string;
65
+ observedAtMs: number;
66
+ metadata: Record<string, unknown>;
67
+ };
68
+
69
+ type ControlPlaneUsageLimit = {
70
+ orgId: string;
71
+ projectId: string | null;
72
+ metric: string;
73
+ unit: string;
74
+ period: string;
75
+ limitQuantity: number;
76
+ updatedAtMs: number;
77
+ };
78
+
79
+ type ControlPlaneUsageLimitCheck = ControlPlaneUsageLimit & {
80
+ usedQuantity: number;
81
+ remainingQuantity: number;
82
+ exceeded: boolean;
83
+ };
84
+
85
+ type ControlPlaneUsageSummary = {
86
+ orgId: string;
87
+ metric: string;
88
+ unit: string;
89
+ quantity: number;
90
+ };
91
+
92
+ type ControlPlaneSecretRef = {
93
+ orgId: string;
94
+ projectId: string | null;
95
+ name: string;
96
+ provider: string;
97
+ ref: string;
98
+ createdBy: string | null;
99
+ createdAtMs: number;
100
+ rotatedAtMs: number | null;
101
+ };
102
+
103
+ type ControlPlaneAuditEvent = {
104
+ id: number;
105
+ orgId: string;
106
+ projectId: string | null;
107
+ actorId: string | null;
108
+ action: string;
109
+ targetType: string;
110
+ targetId: string | null;
111
+ occurredAtMs: number;
112
+ metadata: Record<string, unknown>;
113
+ };
114
+
115
+ type ControlPlaneExport = {
116
+ exportedAtMs: number;
117
+ org: ControlPlaneOrg;
118
+ projects: ControlPlaneProject[];
119
+ teams: ControlPlaneTeam[];
120
+ billing: ControlPlaneBillingAccount | null;
121
+ identityProviders: ControlPlaneIdentityProvider[];
122
+ usage: ControlPlaneUsageSummary[];
123
+ usageLimits: ControlPlaneUsageLimit[];
124
+ secretRefs: ControlPlaneSecretRef[];
125
+ auditEvents: ControlPlaneAuditEvent[];
126
+ };
127
+
128
+ declare function ensureControlPlaneTables(sqlite: ControlPlaneSqlite): void;
129
+
130
+ declare class ControlPlaneStore {
131
+ constructor(sqlite: ControlPlaneSqlite);
132
+
133
+ createOrg(input: { orgId?: string; slug: string; name: string; createdAtMs?: number }): ControlPlaneOrg;
134
+ getOrg(orgId: string): ControlPlaneOrg | null;
135
+ createTeam(input: { orgId: string; teamId?: string; slug: string; name: string; createdAtMs?: number }): ControlPlaneTeam;
136
+ addTeamMember(input: { orgId: string; teamId: string; userId: string; role?: string; createdAtMs?: number }): void;
137
+ createProject(input: { orgId: string; projectId?: string; slug: string; name: string; metadata?: Record<string, unknown>; createdAtMs?: number }): ControlPlaneProject;
138
+ addProjectTeam(input: { orgId: string; projectId: string; teamId: string; role?: string; createdAtMs?: number }): void;
139
+ upsertBillingAccount(input: { orgId: string; plan: string; billingCustomerId?: string | null; status?: string; updatedAtMs?: number }): ControlPlaneBillingAccount;
140
+ upsertIdentityProvider(input: { orgId: string; providerId?: string; type: string; issuer: string; ssoUrl?: string | null; certificateRef?: string | null; status?: string; metadata?: Record<string, unknown>; createdAtMs?: number; updatedAtMs?: number }): ControlPlaneIdentityProvider;
141
+ listIdentityProviders(input: { orgId: string; status?: string }): ControlPlaneIdentityProvider[];
142
+ recordUsage(input: { orgId: string; projectId?: string | null; runId?: string | null; metric: string; quantity: number; unit?: string; observedAtMs?: number; metadata?: Record<string, unknown> }): ControlPlaneUsageEvent;
143
+ summarizeUsage(input: { orgId: string; sinceMs?: number; untilMs?: number }): ControlPlaneUsageSummary[];
144
+ setUsageLimit(input: { orgId: string; projectId?: string | null; metric: string; unit?: string; period?: string; limitQuantity: number; updatedAtMs?: number }): ControlPlaneUsageLimit;
145
+ checkUsageLimit(input: { orgId: string; projectId?: string | null; metric: string; unit?: string; period?: string; sinceMs?: number; untilMs?: number }): ControlPlaneUsageLimitCheck | null;
146
+ putSecretRef(input: { orgId: string; projectId?: string | null; name: string; provider: string; ref: string; createdBy?: string | null; createdAtMs?: number; rotatedAtMs?: number | null }): ControlPlaneSecretRef;
147
+ listSecretRefs(input: { orgId: string; projectId?: string | null }): ControlPlaneSecretRef[];
148
+ recordAuditEvent(input: { orgId: string; projectId?: string | null; actorId?: string | null; action: string; targetType: string; targetId?: string | null; occurredAtMs?: number; metadata?: Record<string, unknown> }): ControlPlaneAuditEvent;
149
+ exportOrgAudit(input: { orgId: string; sinceMs?: number; untilMs?: number; exportedAtMs?: number }): ControlPlaneExport;
150
+ }
151
+
152
+ export { type ControlPlaneAuditEvent, type ControlPlaneBillingAccount, type ControlPlaneExport, type ControlPlaneIdentityProvider, type ControlPlaneOrg, type ControlPlaneProject, type ControlPlaneSecretRef, type ControlPlaneSqlite, ControlPlaneStore, type ControlPlaneTeam, type ControlPlaneUsageEvent, type ControlPlaneUsageLimit, type ControlPlaneUsageLimitCheck, type ControlPlaneUsageSummary, ensureControlPlaneTables };
package/src/index.js ADDED
@@ -0,0 +1,1064 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
3
+
4
+ /**
5
+ * @typedef {import("./index.d.ts").ControlPlaneSqlite} ControlPlaneSqlite
6
+ * @typedef {import("./index.d.ts").ControlPlaneOrg} ControlPlaneOrg
7
+ * @typedef {import("./index.d.ts").ControlPlaneTeam} ControlPlaneTeam
8
+ * @typedef {import("./index.d.ts").ControlPlaneProject} ControlPlaneProject
9
+ * @typedef {import("./index.d.ts").ControlPlaneBillingAccount} ControlPlaneBillingAccount
10
+ * @typedef {import("./index.d.ts").ControlPlaneIdentityProvider} ControlPlaneIdentityProvider
11
+ * @typedef {import("./index.d.ts").ControlPlaneUsageEvent} ControlPlaneUsageEvent
12
+ * @typedef {import("./index.d.ts").ControlPlaneUsageLimit} ControlPlaneUsageLimit
13
+ * @typedef {import("./index.d.ts").ControlPlaneUsageLimitCheck} ControlPlaneUsageLimitCheck
14
+ * @typedef {import("./index.d.ts").ControlPlaneUsageSummary} ControlPlaneUsageSummary
15
+ * @typedef {import("./index.d.ts").ControlPlaneSecretRef} ControlPlaneSecretRef
16
+ * @typedef {import("./index.d.ts").ControlPlaneAuditEvent} ControlPlaneAuditEvent
17
+ * @typedef {import("./index.d.ts").ControlPlaneExport} ControlPlaneExport
18
+ */
19
+
20
+ const SLUG_RE = /^(?:[a-z0-9]|[a-z0-9][a-z0-9-]{0,62}[a-z0-9])$/;
21
+ const ID_RE = /^[A-Za-z0-9:_-]{1,128}$/;
22
+
23
+ /**
24
+ * @param {ControlPlaneSqlite} sqlite
25
+ */
26
+ export function ensureControlPlaneTables(sqlite) {
27
+ sqlite.exec("PRAGMA foreign_keys = ON");
28
+ sqlite.exec(`
29
+ CREATE TABLE IF NOT EXISTS _smithers_cp_orgs (
30
+ org_id TEXT PRIMARY KEY,
31
+ slug TEXT NOT NULL UNIQUE,
32
+ name TEXT NOT NULL,
33
+ created_at_ms INTEGER NOT NULL
34
+ );
35
+
36
+ CREATE TABLE IF NOT EXISTS _smithers_cp_teams (
37
+ org_id TEXT NOT NULL,
38
+ team_id TEXT NOT NULL,
39
+ slug TEXT NOT NULL,
40
+ name TEXT NOT NULL,
41
+ created_at_ms INTEGER NOT NULL,
42
+ PRIMARY KEY (org_id, team_id),
43
+ UNIQUE (org_id, slug),
44
+ FOREIGN KEY (org_id) REFERENCES _smithers_cp_orgs(org_id) ON DELETE CASCADE
45
+ );
46
+
47
+ CREATE TABLE IF NOT EXISTS _smithers_cp_team_members (
48
+ org_id TEXT NOT NULL,
49
+ team_id TEXT NOT NULL,
50
+ user_id TEXT NOT NULL,
51
+ role TEXT NOT NULL,
52
+ created_at_ms INTEGER NOT NULL,
53
+ PRIMARY KEY (org_id, team_id, user_id),
54
+ FOREIGN KEY (org_id, team_id) REFERENCES _smithers_cp_teams(org_id, team_id) ON DELETE CASCADE
55
+ );
56
+
57
+ CREATE TABLE IF NOT EXISTS _smithers_cp_projects (
58
+ org_id TEXT NOT NULL,
59
+ project_id TEXT NOT NULL,
60
+ slug TEXT NOT NULL,
61
+ name TEXT NOT NULL,
62
+ metadata_json TEXT NOT NULL DEFAULT '{}',
63
+ created_at_ms INTEGER NOT NULL,
64
+ PRIMARY KEY (org_id, project_id),
65
+ UNIQUE (org_id, slug),
66
+ FOREIGN KEY (org_id) REFERENCES _smithers_cp_orgs(org_id) ON DELETE CASCADE
67
+ );
68
+
69
+ CREATE TABLE IF NOT EXISTS _smithers_cp_project_teams (
70
+ org_id TEXT NOT NULL,
71
+ project_id TEXT NOT NULL,
72
+ team_id TEXT NOT NULL,
73
+ role TEXT NOT NULL,
74
+ created_at_ms INTEGER NOT NULL,
75
+ PRIMARY KEY (org_id, project_id, team_id),
76
+ FOREIGN KEY (org_id, project_id) REFERENCES _smithers_cp_projects(org_id, project_id) ON DELETE CASCADE,
77
+ FOREIGN KEY (org_id, team_id) REFERENCES _smithers_cp_teams(org_id, team_id) ON DELETE CASCADE
78
+ );
79
+
80
+ CREATE TABLE IF NOT EXISTS _smithers_cp_billing_accounts (
81
+ org_id TEXT PRIMARY KEY,
82
+ plan TEXT NOT NULL,
83
+ billing_customer_id TEXT,
84
+ status TEXT NOT NULL,
85
+ updated_at_ms INTEGER NOT NULL,
86
+ FOREIGN KEY (org_id) REFERENCES _smithers_cp_orgs(org_id) ON DELETE CASCADE
87
+ );
88
+
89
+ CREATE TABLE IF NOT EXISTS _smithers_cp_identity_providers (
90
+ org_id TEXT NOT NULL,
91
+ provider_id TEXT NOT NULL,
92
+ type TEXT NOT NULL,
93
+ issuer TEXT NOT NULL,
94
+ sso_url TEXT,
95
+ certificate_ref TEXT,
96
+ status TEXT NOT NULL,
97
+ metadata_json TEXT NOT NULL DEFAULT '{}',
98
+ created_at_ms INTEGER NOT NULL,
99
+ updated_at_ms INTEGER NOT NULL,
100
+ PRIMARY KEY (org_id, provider_id),
101
+ FOREIGN KEY (org_id) REFERENCES _smithers_cp_orgs(org_id) ON DELETE CASCADE
102
+ );
103
+
104
+ CREATE INDEX IF NOT EXISTS _smithers_cp_idp_org_status_idx
105
+ ON _smithers_cp_identity_providers(org_id, status);
106
+
107
+ CREATE TABLE IF NOT EXISTS _smithers_cp_usage_events (
108
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
109
+ org_id TEXT NOT NULL,
110
+ project_id TEXT,
111
+ run_id TEXT,
112
+ metric TEXT NOT NULL,
113
+ quantity REAL NOT NULL,
114
+ unit TEXT NOT NULL,
115
+ observed_at_ms INTEGER NOT NULL,
116
+ metadata_json TEXT NOT NULL DEFAULT '{}',
117
+ FOREIGN KEY (org_id) REFERENCES _smithers_cp_orgs(org_id) ON DELETE CASCADE,
118
+ FOREIGN KEY (org_id, project_id) REFERENCES _smithers_cp_projects(org_id, project_id) ON DELETE CASCADE
119
+ );
120
+
121
+ CREATE INDEX IF NOT EXISTS _smithers_cp_usage_org_time_idx
122
+ ON _smithers_cp_usage_events(org_id, observed_at_ms);
123
+
124
+ CREATE TABLE IF NOT EXISTS _smithers_cp_usage_limits (
125
+ org_id TEXT NOT NULL,
126
+ project_key TEXT NOT NULL,
127
+ project_id TEXT,
128
+ metric TEXT NOT NULL,
129
+ unit TEXT NOT NULL,
130
+ period TEXT NOT NULL,
131
+ limit_quantity REAL NOT NULL,
132
+ updated_at_ms INTEGER NOT NULL,
133
+ PRIMARY KEY (org_id, project_key, metric, unit, period),
134
+ FOREIGN KEY (org_id) REFERENCES _smithers_cp_orgs(org_id) ON DELETE CASCADE
135
+ );
136
+
137
+ CREATE INDEX IF NOT EXISTS _smithers_cp_usage_limits_org_idx
138
+ ON _smithers_cp_usage_limits(org_id, metric, unit, period);
139
+
140
+ CREATE TABLE IF NOT EXISTS _smithers_cp_secret_refs (
141
+ org_id TEXT NOT NULL,
142
+ project_key TEXT NOT NULL,
143
+ project_id TEXT,
144
+ name TEXT NOT NULL,
145
+ provider TEXT NOT NULL,
146
+ ref TEXT NOT NULL,
147
+ created_by TEXT,
148
+ created_at_ms INTEGER NOT NULL,
149
+ rotated_at_ms INTEGER,
150
+ PRIMARY KEY (org_id, project_key, name),
151
+ FOREIGN KEY (org_id) REFERENCES _smithers_cp_orgs(org_id) ON DELETE CASCADE,
152
+ FOREIGN KEY (org_id, project_id) REFERENCES _smithers_cp_projects(org_id, project_id) ON DELETE CASCADE
153
+ );
154
+
155
+ CREATE TABLE IF NOT EXISTS _smithers_cp_audit_events (
156
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
157
+ org_id TEXT NOT NULL,
158
+ project_id TEXT,
159
+ actor_id TEXT,
160
+ action TEXT NOT NULL,
161
+ target_type TEXT NOT NULL,
162
+ target_id TEXT,
163
+ occurred_at_ms INTEGER NOT NULL,
164
+ metadata_json TEXT NOT NULL DEFAULT '{}',
165
+ FOREIGN KEY (org_id) REFERENCES _smithers_cp_orgs(org_id) ON DELETE CASCADE,
166
+ FOREIGN KEY (org_id, project_id) REFERENCES _smithers_cp_projects(org_id, project_id) ON DELETE CASCADE
167
+ );
168
+
169
+ CREATE INDEX IF NOT EXISTS _smithers_cp_audit_org_time_idx
170
+ ON _smithers_cp_audit_events(org_id, occurred_at_ms);
171
+ `);
172
+ migrateSecretRefsProjectKey(sqlite);
173
+ }
174
+
175
+ /**
176
+ * @param {ControlPlaneSqlite} sqlite
177
+ */
178
+ function migrateSecretRefsProjectKey(sqlite) {
179
+ const columns = sqlite.query("PRAGMA table_info(_smithers_cp_secret_refs)").all();
180
+ if (columns.some((column) => String(column.name) === "project_key")) {
181
+ return;
182
+ }
183
+ sqlite.exec(`
184
+ ALTER TABLE _smithers_cp_secret_refs RENAME TO _smithers_cp_secret_refs_legacy;
185
+
186
+ CREATE TABLE _smithers_cp_secret_refs (
187
+ org_id TEXT NOT NULL,
188
+ project_key TEXT NOT NULL,
189
+ project_id TEXT,
190
+ name TEXT NOT NULL,
191
+ provider TEXT NOT NULL,
192
+ ref TEXT NOT NULL,
193
+ created_by TEXT,
194
+ created_at_ms INTEGER NOT NULL,
195
+ rotated_at_ms INTEGER,
196
+ PRIMARY KEY (org_id, project_key, name),
197
+ FOREIGN KEY (org_id) REFERENCES _smithers_cp_orgs(org_id) ON DELETE CASCADE,
198
+ FOREIGN KEY (org_id, project_id) REFERENCES _smithers_cp_projects(org_id, project_id) ON DELETE CASCADE
199
+ );
200
+
201
+ INSERT OR REPLACE INTO _smithers_cp_secret_refs (
202
+ org_id, project_key, project_id, name, provider, ref, created_by, created_at_ms, rotated_at_ms
203
+ )
204
+ SELECT
205
+ org_id,
206
+ COALESCE(project_id, '__org__') AS project_key,
207
+ project_id,
208
+ name,
209
+ provider,
210
+ ref,
211
+ created_by,
212
+ created_at_ms,
213
+ rotated_at_ms
214
+ FROM _smithers_cp_secret_refs_legacy
215
+ ORDER BY created_at_ms;
216
+
217
+ DROP TABLE _smithers_cp_secret_refs_legacy;
218
+ `);
219
+ }
220
+
221
+ /**
222
+ * @param {string} field
223
+ * @param {unknown} value
224
+ */
225
+ function nonEmptyString(field, value) {
226
+ if (typeof value !== "string" || value.trim().length === 0) {
227
+ throw new SmithersError("INVALID_INPUT", `${field} is required.`);
228
+ }
229
+ return value.trim();
230
+ }
231
+
232
+ /**
233
+ * @param {string} field
234
+ * @param {unknown} value
235
+ */
236
+ function optionalId(field, value) {
237
+ const id = typeof value === "string" && value.trim() ? value.trim() : randomUUID();
238
+ if (!ID_RE.test(id)) {
239
+ throw new SmithersError("INVALID_INPUT", `${field} must match ${ID_RE}.`, { field });
240
+ }
241
+ return id;
242
+ }
243
+
244
+ /**
245
+ * @param {string} field
246
+ * @param {unknown} value
247
+ */
248
+ function requiredId(field, value) {
249
+ const id = nonEmptyString(field, value);
250
+ if (!ID_RE.test(id)) {
251
+ throw new SmithersError("INVALID_INPUT", `${field} must match ${ID_RE}.`, { field });
252
+ }
253
+ return id;
254
+ }
255
+
256
+ /**
257
+ * @param {string} field
258
+ * @param {unknown} value
259
+ */
260
+ function slug(field, value) {
261
+ const out = nonEmptyString(field, value);
262
+ if (!SLUG_RE.test(out)) {
263
+ throw new SmithersError("INVALID_INPUT", `${field} must be a lowercase slug.`, { field });
264
+ }
265
+ return out;
266
+ }
267
+
268
+ /**
269
+ * @param {unknown} value
270
+ */
271
+ function jsonObject(value) {
272
+ if (value === undefined) {
273
+ return {};
274
+ }
275
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
276
+ throw new SmithersError("INVALID_INPUT", "metadata must be a JSON object.");
277
+ }
278
+ return value;
279
+ }
280
+
281
+ /**
282
+ * @param {unknown} value
283
+ */
284
+ function parseJsonObject(value) {
285
+ if (typeof value !== "string" || value.trim().length === 0) {
286
+ return {};
287
+ }
288
+ const parsed = JSON.parse(value);
289
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
290
+ }
291
+
292
+ /**
293
+ * @param {unknown} value
294
+ */
295
+ function timestamp(value) {
296
+ const n = value === undefined ? Date.now() : Number(value);
297
+ if (!Number.isFinite(n) || n < 0) {
298
+ throw new SmithersError("INVALID_INPUT", "timestamp must be a non-negative finite number.");
299
+ }
300
+ return Math.floor(n);
301
+ }
302
+
303
+ /**
304
+ * @param {unknown} value
305
+ */
306
+ function quantity(value) {
307
+ const n = Number(value);
308
+ if (!Number.isFinite(n) || n < 0) {
309
+ throw new SmithersError("INVALID_INPUT", "quantity must be a non-negative finite number.");
310
+ }
311
+ return n;
312
+ }
313
+
314
+ /**
315
+ * @param {string | null} projectId
316
+ */
317
+ function projectKey(projectId) {
318
+ return projectId ?? "__org__";
319
+ }
320
+
321
+ /**
322
+ * @param {Record<string, unknown>} row
323
+ * @returns {ControlPlaneOrg}
324
+ */
325
+ function orgRow(row) {
326
+ return {
327
+ orgId: String(row.orgId),
328
+ slug: String(row.slug),
329
+ name: String(row.name),
330
+ createdAtMs: Number(row.createdAtMs),
331
+ };
332
+ }
333
+
334
+ /**
335
+ * @param {Record<string, unknown>} row
336
+ * @returns {ControlPlaneTeam}
337
+ */
338
+ function teamRow(row) {
339
+ return {
340
+ orgId: String(row.orgId),
341
+ teamId: String(row.teamId),
342
+ slug: String(row.slug),
343
+ name: String(row.name),
344
+ createdAtMs: Number(row.createdAtMs),
345
+ };
346
+ }
347
+
348
+ /**
349
+ * @param {Record<string, unknown>} row
350
+ * @returns {ControlPlaneProject}
351
+ */
352
+ function projectRow(row) {
353
+ return {
354
+ orgId: String(row.orgId),
355
+ projectId: String(row.projectId),
356
+ slug: String(row.slug),
357
+ name: String(row.name),
358
+ metadata: parseJsonObject(row.metadataJson),
359
+ createdAtMs: Number(row.createdAtMs),
360
+ };
361
+ }
362
+
363
+ /**
364
+ * @param {Record<string, unknown>} row
365
+ * @returns {ControlPlaneBillingAccount}
366
+ */
367
+ function billingRow(row) {
368
+ return {
369
+ orgId: String(row.orgId),
370
+ plan: String(row.plan),
371
+ billingCustomerId: row.billingCustomerId === null ? null : String(row.billingCustomerId),
372
+ status: String(row.status),
373
+ updatedAtMs: Number(row.updatedAtMs),
374
+ };
375
+ }
376
+
377
+ /**
378
+ * @param {Record<string, unknown>} row
379
+ * @returns {ControlPlaneIdentityProvider}
380
+ */
381
+ function identityProviderRow(row) {
382
+ return {
383
+ orgId: String(row.orgId),
384
+ providerId: String(row.providerId),
385
+ type: String(row.type),
386
+ issuer: String(row.issuer),
387
+ ssoUrl: row.ssoUrl === null ? null : String(row.ssoUrl),
388
+ certificateRef: row.certificateRef === null ? null : String(row.certificateRef),
389
+ status: String(row.status),
390
+ metadata: parseJsonObject(row.metadataJson),
391
+ createdAtMs: Number(row.createdAtMs),
392
+ updatedAtMs: Number(row.updatedAtMs),
393
+ };
394
+ }
395
+
396
+ /**
397
+ * @param {Record<string, unknown>} row
398
+ * @returns {ControlPlaneUsageEvent}
399
+ */
400
+ function usageRow(row) {
401
+ return {
402
+ id: Number(row.id),
403
+ orgId: String(row.orgId),
404
+ projectId: row.projectId === null ? null : String(row.projectId),
405
+ runId: row.runId === null ? null : String(row.runId),
406
+ metric: String(row.metric),
407
+ quantity: Number(row.quantity),
408
+ unit: String(row.unit),
409
+ observedAtMs: Number(row.observedAtMs),
410
+ metadata: parseJsonObject(row.metadataJson),
411
+ };
412
+ }
413
+
414
+ /**
415
+ * @param {Record<string, unknown>} row
416
+ * @returns {ControlPlaneUsageLimit}
417
+ */
418
+ function usageLimitRow(row) {
419
+ return {
420
+ orgId: String(row.orgId),
421
+ projectId: row.projectId === null ? null : String(row.projectId),
422
+ metric: String(row.metric),
423
+ unit: String(row.unit),
424
+ period: String(row.period),
425
+ limitQuantity: Number(row.limitQuantity),
426
+ updatedAtMs: Number(row.updatedAtMs),
427
+ };
428
+ }
429
+
430
+ /**
431
+ * @param {Record<string, unknown>} row
432
+ * @returns {ControlPlaneSecretRef}
433
+ */
434
+ function secretRefRow(row) {
435
+ return {
436
+ orgId: String(row.orgId),
437
+ projectId: row.projectId === null ? null : String(row.projectId),
438
+ name: String(row.name),
439
+ provider: String(row.provider),
440
+ ref: String(row.ref),
441
+ createdBy: row.createdBy === null ? null : String(row.createdBy),
442
+ createdAtMs: Number(row.createdAtMs),
443
+ rotatedAtMs: row.rotatedAtMs === null ? null : Number(row.rotatedAtMs),
444
+ };
445
+ }
446
+
447
+ /**
448
+ * @param {Record<string, unknown>} row
449
+ * @returns {ControlPlaneAuditEvent}
450
+ */
451
+ function auditRow(row) {
452
+ return {
453
+ id: Number(row.id),
454
+ orgId: String(row.orgId),
455
+ projectId: row.projectId === null ? null : String(row.projectId),
456
+ actorId: row.actorId === null ? null : String(row.actorId),
457
+ action: String(row.action),
458
+ targetType: String(row.targetType),
459
+ targetId: row.targetId === null ? null : String(row.targetId),
460
+ occurredAtMs: Number(row.occurredAtMs),
461
+ metadata: parseJsonObject(row.metadataJson),
462
+ };
463
+ }
464
+
465
+ /**
466
+ * @param {ControlPlaneSqlite} sqlite
467
+ * @param {string} orgId
468
+ * @param {string} projectId
469
+ */
470
+ function assertProjectExists(sqlite, orgId, projectId) {
471
+ const row = sqlite.query(`
472
+ SELECT 1 AS ok
473
+ FROM _smithers_cp_projects
474
+ WHERE org_id = ? AND project_id = ?
475
+ LIMIT 1
476
+ `).get(orgId, projectId);
477
+ if (!row) {
478
+ throw new SmithersError("INVALID_INPUT", `Control-plane project not found: ${projectId}`, { orgId, projectId });
479
+ }
480
+ }
481
+
482
+ export class ControlPlaneStore {
483
+ /** @type {ControlPlaneSqlite} */
484
+ sqlite;
485
+
486
+ /**
487
+ * @param {ControlPlaneSqlite} sqlite
488
+ */
489
+ constructor(sqlite) {
490
+ this.sqlite = sqlite;
491
+ ensureControlPlaneTables(sqlite);
492
+ }
493
+
494
+ /**
495
+ * @param {{ orgId?: string; slug: string; name: string; createdAtMs?: number }} input
496
+ * @returns {ControlPlaneOrg}
497
+ */
498
+ createOrg(input) {
499
+ const orgId = optionalId("orgId", input.orgId);
500
+ const createdAtMs = timestamp(input.createdAtMs);
501
+ this.sqlite.query(`
502
+ INSERT INTO _smithers_cp_orgs (org_id, slug, name, created_at_ms)
503
+ VALUES (?, ?, ?, ?)
504
+ `).run(orgId, slug("slug", input.slug), nonEmptyString("name", input.name), createdAtMs);
505
+ this.recordAuditEvent({
506
+ orgId,
507
+ actorId: "system",
508
+ action: "org.create",
509
+ targetType: "org",
510
+ targetId: orgId,
511
+ occurredAtMs: createdAtMs,
512
+ });
513
+ return this.getOrg(orgId);
514
+ }
515
+
516
+ /**
517
+ * @param {string} orgId
518
+ * @returns {ControlPlaneOrg | null}
519
+ */
520
+ getOrg(orgId) {
521
+ const row = this.sqlite.query(`
522
+ SELECT org_id AS orgId, slug, name, created_at_ms AS createdAtMs
523
+ FROM _smithers_cp_orgs
524
+ WHERE org_id = ?
525
+ LIMIT 1
526
+ `).get(requiredId("orgId", orgId));
527
+ return row ? orgRow(row) : null;
528
+ }
529
+
530
+ /**
531
+ * @param {{ orgId: string; teamId?: string; slug: string; name: string; createdAtMs?: number }} input
532
+ * @returns {ControlPlaneTeam}
533
+ */
534
+ createTeam(input) {
535
+ const orgId = requiredId("orgId", input.orgId);
536
+ const teamId = optionalId("teamId", input.teamId);
537
+ const createdAtMs = timestamp(input.createdAtMs);
538
+ this.sqlite.query(`
539
+ INSERT INTO _smithers_cp_teams (org_id, team_id, slug, name, created_at_ms)
540
+ VALUES (?, ?, ?, ?, ?)
541
+ `).run(orgId, teamId, slug("slug", input.slug), nonEmptyString("name", input.name), createdAtMs);
542
+ this.recordAuditEvent({
543
+ orgId,
544
+ action: "team.create",
545
+ targetType: "team",
546
+ targetId: teamId,
547
+ occurredAtMs: createdAtMs,
548
+ });
549
+ const row = this.sqlite.query(`
550
+ SELECT org_id AS orgId, team_id AS teamId, slug, name, created_at_ms AS createdAtMs
551
+ FROM _smithers_cp_teams
552
+ WHERE org_id = ? AND team_id = ?
553
+ LIMIT 1
554
+ `).get(orgId, teamId);
555
+ return teamRow(row);
556
+ }
557
+
558
+ /**
559
+ * @param {{ orgId: string; teamId: string; userId: string; role?: string; createdAtMs?: number }} input
560
+ */
561
+ addTeamMember(input) {
562
+ const orgId = requiredId("orgId", input.orgId);
563
+ const teamId = requiredId("teamId", input.teamId);
564
+ const userId = requiredId("userId", input.userId);
565
+ const role = nonEmptyString("role", input.role ?? "member");
566
+ const createdAtMs = timestamp(input.createdAtMs);
567
+ this.sqlite.query(`
568
+ INSERT INTO _smithers_cp_team_members (org_id, team_id, user_id, role, created_at_ms)
569
+ VALUES (?, ?, ?, ?, ?)
570
+ ON CONFLICT(org_id, team_id, user_id) DO UPDATE SET role = excluded.role
571
+ `).run(orgId, teamId, userId, role, createdAtMs);
572
+ this.recordAuditEvent({
573
+ orgId,
574
+ actorId: userId,
575
+ action: "team.member.upsert",
576
+ targetType: "team",
577
+ targetId: teamId,
578
+ occurredAtMs: createdAtMs,
579
+ metadata: { role },
580
+ });
581
+ }
582
+
583
+ /**
584
+ * @param {{ orgId: string; projectId?: string; slug: string; name: string; metadata?: Record<string, unknown>; createdAtMs?: number }} input
585
+ * @returns {ControlPlaneProject}
586
+ */
587
+ createProject(input) {
588
+ const orgId = requiredId("orgId", input.orgId);
589
+ const projectId = optionalId("projectId", input.projectId);
590
+ const metadata = jsonObject(input.metadata);
591
+ const createdAtMs = timestamp(input.createdAtMs);
592
+ this.sqlite.query(`
593
+ INSERT INTO _smithers_cp_projects (org_id, project_id, slug, name, metadata_json, created_at_ms)
594
+ VALUES (?, ?, ?, ?, ?, ?)
595
+ `).run(orgId, projectId, slug("slug", input.slug), nonEmptyString("name", input.name), JSON.stringify(metadata), createdAtMs);
596
+ this.recordAuditEvent({
597
+ orgId,
598
+ projectId,
599
+ action: "project.create",
600
+ targetType: "project",
601
+ targetId: projectId,
602
+ occurredAtMs: createdAtMs,
603
+ });
604
+ const row = this.sqlite.query(`
605
+ SELECT org_id AS orgId, project_id AS projectId, slug, name, metadata_json AS metadataJson, created_at_ms AS createdAtMs
606
+ FROM _smithers_cp_projects
607
+ WHERE org_id = ? AND project_id = ?
608
+ LIMIT 1
609
+ `).get(orgId, projectId);
610
+ return projectRow(row);
611
+ }
612
+
613
+ /**
614
+ * @param {{ orgId: string; projectId: string; teamId: string; role?: string; createdAtMs?: number }} input
615
+ */
616
+ addProjectTeam(input) {
617
+ const orgId = requiredId("orgId", input.orgId);
618
+ const projectId = requiredId("projectId", input.projectId);
619
+ const teamId = requiredId("teamId", input.teamId);
620
+ const role = nonEmptyString("role", input.role ?? "viewer");
621
+ const createdAtMs = timestamp(input.createdAtMs);
622
+ this.sqlite.query(`
623
+ INSERT INTO _smithers_cp_project_teams (org_id, project_id, team_id, role, created_at_ms)
624
+ VALUES (?, ?, ?, ?, ?)
625
+ ON CONFLICT(org_id, project_id, team_id) DO UPDATE SET role = excluded.role
626
+ `).run(orgId, projectId, teamId, role, createdAtMs);
627
+ this.recordAuditEvent({
628
+ orgId,
629
+ projectId,
630
+ action: "project.team.upsert",
631
+ targetType: "team",
632
+ targetId: teamId,
633
+ occurredAtMs: createdAtMs,
634
+ metadata: { role },
635
+ });
636
+ }
637
+
638
+ /**
639
+ * @param {{ orgId: string; plan: string; billingCustomerId?: string | null; status?: string; updatedAtMs?: number }} input
640
+ * @returns {ControlPlaneBillingAccount}
641
+ */
642
+ upsertBillingAccount(input) {
643
+ const orgId = requiredId("orgId", input.orgId);
644
+ const updatedAtMs = timestamp(input.updatedAtMs);
645
+ const status = nonEmptyString("status", input.status ?? "active");
646
+ this.sqlite.query(`
647
+ INSERT INTO _smithers_cp_billing_accounts (org_id, plan, billing_customer_id, status, updated_at_ms)
648
+ VALUES (?, ?, ?, ?, ?)
649
+ ON CONFLICT(org_id) DO UPDATE SET
650
+ plan = excluded.plan,
651
+ billing_customer_id = excluded.billing_customer_id,
652
+ status = excluded.status,
653
+ updated_at_ms = excluded.updated_at_ms
654
+ `).run(orgId, nonEmptyString("plan", input.plan), input.billingCustomerId ?? null, status, updatedAtMs);
655
+ this.recordAuditEvent({
656
+ orgId,
657
+ action: "billing.account.upsert",
658
+ targetType: "billing_account",
659
+ targetId: orgId,
660
+ occurredAtMs: updatedAtMs,
661
+ metadata: { plan: input.plan, status },
662
+ });
663
+ const row = this.sqlite.query(`
664
+ SELECT org_id AS orgId, plan, billing_customer_id AS billingCustomerId, status, updated_at_ms AS updatedAtMs
665
+ FROM _smithers_cp_billing_accounts
666
+ WHERE org_id = ?
667
+ LIMIT 1
668
+ `).get(orgId);
669
+ return billingRow(row);
670
+ }
671
+
672
+ /**
673
+ * @param {{ orgId: string; providerId?: string; type: "saml" | "oidc" | string; issuer: string; ssoUrl?: string | null; certificateRef?: string | null; status?: string; metadata?: Record<string, unknown>; createdAtMs?: number; updatedAtMs?: number }} input
674
+ * @returns {ControlPlaneIdentityProvider}
675
+ */
676
+ upsertIdentityProvider(input) {
677
+ const orgId = requiredId("orgId", input.orgId);
678
+ const providerId = optionalId("providerId", input.providerId);
679
+ const metadata = jsonObject(input.metadata);
680
+ const updatedAtMs = timestamp(input.updatedAtMs);
681
+ const createdAtMs = timestamp(input.createdAtMs ?? updatedAtMs);
682
+ const status = nonEmptyString("status", input.status ?? "active");
683
+ this.sqlite.query(`
684
+ INSERT INTO _smithers_cp_identity_providers (
685
+ org_id, provider_id, type, issuer, sso_url, certificate_ref, status, metadata_json, created_at_ms, updated_at_ms
686
+ )
687
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
688
+ ON CONFLICT(org_id, provider_id) DO UPDATE SET
689
+ type = excluded.type,
690
+ issuer = excluded.issuer,
691
+ sso_url = excluded.sso_url,
692
+ certificate_ref = excluded.certificate_ref,
693
+ status = excluded.status,
694
+ metadata_json = excluded.metadata_json,
695
+ updated_at_ms = excluded.updated_at_ms
696
+ `).run(
697
+ orgId,
698
+ providerId,
699
+ nonEmptyString("type", input.type),
700
+ nonEmptyString("issuer", input.issuer),
701
+ input.ssoUrl ? nonEmptyString("ssoUrl", input.ssoUrl) : null,
702
+ input.certificateRef ? nonEmptyString("certificateRef", input.certificateRef) : null,
703
+ status,
704
+ JSON.stringify(metadata),
705
+ createdAtMs,
706
+ updatedAtMs,
707
+ );
708
+ this.recordAuditEvent({
709
+ orgId,
710
+ action: "identity_provider.upsert",
711
+ targetType: "identity_provider",
712
+ targetId: providerId,
713
+ occurredAtMs: updatedAtMs,
714
+ metadata: { type: input.type, status },
715
+ });
716
+ const row = this.sqlite.query(`
717
+ SELECT org_id AS orgId, provider_id AS providerId, type, issuer, sso_url AS ssoUrl, certificate_ref AS certificateRef, status, metadata_json AS metadataJson, created_at_ms AS createdAtMs, updated_at_ms AS updatedAtMs
718
+ FROM _smithers_cp_identity_providers
719
+ WHERE org_id = ? AND provider_id = ?
720
+ LIMIT 1
721
+ `).get(orgId, providerId);
722
+ return identityProviderRow(row);
723
+ }
724
+
725
+ /**
726
+ * @param {{ orgId: string; status?: string }} input
727
+ * @returns {ControlPlaneIdentityProvider[]}
728
+ */
729
+ listIdentityProviders(input) {
730
+ const orgId = requiredId("orgId", input.orgId);
731
+ const status = input.status ? nonEmptyString("status", input.status) : null;
732
+ const sql = status
733
+ ? `
734
+ SELECT org_id AS orgId, provider_id AS providerId, type, issuer, sso_url AS ssoUrl, certificate_ref AS certificateRef, status, metadata_json AS metadataJson, created_at_ms AS createdAtMs, updated_at_ms AS updatedAtMs
735
+ FROM _smithers_cp_identity_providers
736
+ WHERE org_id = ? AND status = ?
737
+ ORDER BY provider_id
738
+ `
739
+ : `
740
+ SELECT org_id AS orgId, provider_id AS providerId, type, issuer, sso_url AS ssoUrl, certificate_ref AS certificateRef, status, metadata_json AS metadataJson, created_at_ms AS createdAtMs, updated_at_ms AS updatedAtMs
741
+ FROM _smithers_cp_identity_providers
742
+ WHERE org_id = ?
743
+ ORDER BY provider_id
744
+ `;
745
+ const args = status ? [orgId, status] : [orgId];
746
+ return this.sqlite.query(sql).all(...args).map(identityProviderRow);
747
+ }
748
+
749
+ /**
750
+ * @param {{ orgId: string; projectId?: string | null; runId?: string | null; metric: string; quantity: number; unit?: string; observedAtMs?: number; metadata?: Record<string, unknown> }} input
751
+ * @returns {ControlPlaneUsageEvent}
752
+ */
753
+ recordUsage(input) {
754
+ const orgId = requiredId("orgId", input.orgId);
755
+ const projectId = input.projectId ? requiredId("projectId", input.projectId) : null;
756
+ const observedAtMs = timestamp(input.observedAtMs);
757
+ const metadata = jsonObject(input.metadata);
758
+ this.sqlite.query(`
759
+ INSERT INTO _smithers_cp_usage_events (org_id, project_id, run_id, metric, quantity, unit, observed_at_ms, metadata_json)
760
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
761
+ `).run(
762
+ orgId,
763
+ projectId,
764
+ input.runId ? requiredId("runId", input.runId) : null,
765
+ nonEmptyString("metric", input.metric),
766
+ quantity(input.quantity),
767
+ nonEmptyString("unit", input.unit ?? "count"),
768
+ observedAtMs,
769
+ JSON.stringify(metadata),
770
+ );
771
+ const id = this.sqlite.query("SELECT last_insert_rowid() AS id").get().id;
772
+ const row = this.sqlite.query(`
773
+ SELECT id, org_id AS orgId, project_id AS projectId, run_id AS runId, metric, quantity, unit, observed_at_ms AS observedAtMs, metadata_json AS metadataJson
774
+ FROM _smithers_cp_usage_events
775
+ WHERE id = ?
776
+ LIMIT 1
777
+ `).get(id);
778
+ return usageRow(row);
779
+ }
780
+
781
+ /**
782
+ * @param {{ orgId: string; sinceMs?: number; untilMs?: number }} input
783
+ * @returns {ControlPlaneUsageSummary[]}
784
+ */
785
+ summarizeUsage(input) {
786
+ const orgId = requiredId("orgId", input.orgId);
787
+ const sinceMs = input.sinceMs === undefined ? 0 : timestamp(input.sinceMs);
788
+ const untilMs = input.untilMs === undefined ? Number.MAX_SAFE_INTEGER : timestamp(input.untilMs);
789
+ return this.sqlite.query(`
790
+ SELECT org_id AS orgId, metric, unit, SUM(quantity) AS quantity
791
+ FROM _smithers_cp_usage_events
792
+ WHERE org_id = ? AND observed_at_ms >= ? AND observed_at_ms <= ?
793
+ GROUP BY org_id, metric, unit
794
+ ORDER BY metric, unit
795
+ `).all(orgId, sinceMs, untilMs).map((row) => ({
796
+ orgId: String(row.orgId),
797
+ metric: String(row.metric),
798
+ unit: String(row.unit),
799
+ quantity: Number(row.quantity),
800
+ }));
801
+ }
802
+
803
+ /**
804
+ * @param {{ orgId: string; projectId?: string | null; metric: string; unit?: string; period?: string; limitQuantity: number; updatedAtMs?: number }} input
805
+ * @returns {ControlPlaneUsageLimit}
806
+ */
807
+ setUsageLimit(input) {
808
+ const orgId = requiredId("orgId", input.orgId);
809
+ const projectId = input.projectId ? requiredId("projectId", input.projectId) : null;
810
+ if (projectId) {
811
+ assertProjectExists(this.sqlite, orgId, projectId);
812
+ }
813
+ const metric = nonEmptyString("metric", input.metric);
814
+ const unit = nonEmptyString("unit", input.unit ?? "count");
815
+ const period = nonEmptyString("period", input.period ?? "monthly");
816
+ const limitValue = quantity(input.limitQuantity);
817
+ const updatedAtMs = timestamp(input.updatedAtMs);
818
+ this.sqlite.query(`
819
+ INSERT INTO _smithers_cp_usage_limits (org_id, project_key, project_id, metric, unit, period, limit_quantity, updated_at_ms)
820
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
821
+ ON CONFLICT(org_id, project_key, metric, unit, period) DO UPDATE SET
822
+ project_id = excluded.project_id,
823
+ limit_quantity = excluded.limit_quantity,
824
+ updated_at_ms = excluded.updated_at_ms
825
+ `).run(orgId, projectKey(projectId), projectId, metric, unit, period, limitValue, updatedAtMs);
826
+ this.recordAuditEvent({
827
+ orgId,
828
+ projectId,
829
+ action: "usage_limit.upsert",
830
+ targetType: "usage_limit",
831
+ targetId: projectId ?? orgId,
832
+ occurredAtMs: updatedAtMs,
833
+ metadata: { metric, unit, period, limitQuantity: limitValue },
834
+ });
835
+ const row = this.sqlite.query(`
836
+ SELECT org_id AS orgId, project_id AS projectId, metric, unit, period, limit_quantity AS limitQuantity, updated_at_ms AS updatedAtMs
837
+ FROM _smithers_cp_usage_limits
838
+ WHERE org_id = ? AND project_key = ? AND metric = ? AND unit = ? AND period = ?
839
+ LIMIT 1
840
+ `).get(orgId, projectKey(projectId), metric, unit, period);
841
+ return usageLimitRow(row);
842
+ }
843
+
844
+ /**
845
+ * @param {{ orgId: string; projectId?: string | null; metric: string; unit?: string; period?: string; sinceMs?: number; untilMs?: number }} input
846
+ * @returns {ControlPlaneUsageLimitCheck | null}
847
+ */
848
+ checkUsageLimit(input) {
849
+ const orgId = requiredId("orgId", input.orgId);
850
+ const projectId = input.projectId ? requiredId("projectId", input.projectId) : null;
851
+ const metric = nonEmptyString("metric", input.metric);
852
+ const unit = nonEmptyString("unit", input.unit ?? "count");
853
+ const period = nonEmptyString("period", input.period ?? "monthly");
854
+ const limitRowRaw = this.sqlite.query(`
855
+ SELECT org_id AS orgId, project_id AS projectId, metric, unit, period, limit_quantity AS limitQuantity, updated_at_ms AS updatedAtMs
856
+ FROM _smithers_cp_usage_limits
857
+ WHERE org_id = ? AND project_key = ? AND metric = ? AND unit = ? AND period = ?
858
+ LIMIT 1
859
+ `).get(orgId, projectKey(projectId), metric, unit, period);
860
+ if (!limitRowRaw) {
861
+ return null;
862
+ }
863
+ const sinceMs = input.sinceMs === undefined ? 0 : timestamp(input.sinceMs);
864
+ const untilMs = input.untilMs === undefined ? Number.MAX_SAFE_INTEGER : timestamp(input.untilMs);
865
+ const usageSql = projectId
866
+ ? `
867
+ SELECT COALESCE(SUM(quantity), 0) AS usedQuantity
868
+ FROM _smithers_cp_usage_events
869
+ WHERE org_id = ? AND project_id = ? AND metric = ? AND unit = ? AND observed_at_ms >= ? AND observed_at_ms <= ?
870
+ `
871
+ : `
872
+ SELECT COALESCE(SUM(quantity), 0) AS usedQuantity
873
+ FROM _smithers_cp_usage_events
874
+ WHERE org_id = ? AND metric = ? AND unit = ? AND observed_at_ms >= ? AND observed_at_ms <= ?
875
+ `;
876
+ const usageArgs = projectId
877
+ ? [orgId, projectId, metric, unit, sinceMs, untilMs]
878
+ : [orgId, metric, unit, sinceMs, untilMs];
879
+ const usageRowRaw = this.sqlite.query(usageSql).get(...usageArgs);
880
+ const limit = usageLimitRow(limitRowRaw);
881
+ const usedQuantity = Number(usageRowRaw?.usedQuantity ?? 0);
882
+ const remainingQuantity = Math.max(0, limit.limitQuantity - usedQuantity);
883
+ return {
884
+ ...limit,
885
+ usedQuantity,
886
+ remainingQuantity,
887
+ exceeded: usedQuantity > limit.limitQuantity,
888
+ };
889
+ }
890
+
891
+ /**
892
+ * @param {{ orgId: string; projectId?: string | null; name: string; provider: string; ref: string; createdBy?: string | null; createdAtMs?: number; rotatedAtMs?: number | null }} input
893
+ * @returns {ControlPlaneSecretRef}
894
+ */
895
+ putSecretRef(input) {
896
+ const orgId = requiredId("orgId", input.orgId);
897
+ const projectId = input.projectId ? requiredId("projectId", input.projectId) : null;
898
+ if (projectId) {
899
+ assertProjectExists(this.sqlite, orgId, projectId);
900
+ }
901
+ const secretProjectKey = projectKey(projectId);
902
+ const name = nonEmptyString("name", input.name);
903
+ const createdAtMs = timestamp(input.createdAtMs);
904
+ const rotatedAtMs = input.rotatedAtMs === undefined || input.rotatedAtMs === null
905
+ ? null
906
+ : timestamp(input.rotatedAtMs);
907
+ const provider = nonEmptyString("provider", input.provider);
908
+ const ref = nonEmptyString("ref", input.ref);
909
+ const createdBy = input.createdBy ? requiredId("createdBy", input.createdBy) : null;
910
+ this.sqlite.query(`
911
+ DELETE FROM _smithers_cp_secret_refs
912
+ WHERE org_id = ? AND project_key = ? AND name = ?
913
+ `).run(orgId, secretProjectKey, name);
914
+ this.sqlite.query(`
915
+ INSERT INTO _smithers_cp_secret_refs (org_id, project_key, project_id, name, provider, ref, created_by, created_at_ms, rotated_at_ms)
916
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
917
+ `).run(
918
+ orgId,
919
+ secretProjectKey,
920
+ projectId,
921
+ name,
922
+ provider,
923
+ ref,
924
+ createdBy,
925
+ createdAtMs,
926
+ rotatedAtMs,
927
+ );
928
+ this.recordAuditEvent({
929
+ orgId,
930
+ projectId,
931
+ actorId: createdBy,
932
+ action: "secret_ref.upsert",
933
+ targetType: "secret_ref",
934
+ targetId: name,
935
+ occurredAtMs: rotatedAtMs ?? createdAtMs,
936
+ metadata: { provider },
937
+ });
938
+ const row = this.sqlite.query(`
939
+ SELECT org_id AS orgId, project_id AS projectId, name, provider, ref, created_by AS createdBy, created_at_ms AS createdAtMs, rotated_at_ms AS rotatedAtMs
940
+ FROM _smithers_cp_secret_refs
941
+ WHERE org_id = ? AND project_key = ? AND name = ?
942
+ LIMIT 1
943
+ `).get(orgId, secretProjectKey, name);
944
+ return secretRefRow(row);
945
+ }
946
+
947
+ /**
948
+ * @param {{ orgId: string; projectId?: string | null }} input
949
+ * @returns {ControlPlaneSecretRef[]}
950
+ */
951
+ listSecretRefs(input) {
952
+ const orgId = requiredId("orgId", input.orgId);
953
+ const projectId = input.projectId === undefined ? undefined : input.projectId;
954
+ const sql = projectId === undefined
955
+ ? `
956
+ SELECT org_id AS orgId, project_id AS projectId, name, provider, ref, created_by AS createdBy, created_at_ms AS createdAtMs, rotated_at_ms AS rotatedAtMs
957
+ FROM _smithers_cp_secret_refs
958
+ WHERE org_id = ?
959
+ ORDER BY name
960
+ `
961
+ : `
962
+ SELECT org_id AS orgId, project_id AS projectId, name, provider, ref, created_by AS createdBy, created_at_ms AS createdAtMs, rotated_at_ms AS rotatedAtMs
963
+ FROM _smithers_cp_secret_refs
964
+ WHERE org_id = ? AND project_key = ?
965
+ ORDER BY name
966
+ `;
967
+ const args = projectId === undefined ? [orgId] : [orgId, projectKey(projectId ? requiredId("projectId", projectId) : null)];
968
+ return this.sqlite.query(sql).all(...args).map(secretRefRow);
969
+ }
970
+
971
+ /**
972
+ * @param {{ orgId: string; projectId?: string | null; actorId?: string | null; action: string; targetType: string; targetId?: string | null; occurredAtMs?: number; metadata?: Record<string, unknown> }} input
973
+ * @returns {ControlPlaneAuditEvent}
974
+ */
975
+ recordAuditEvent(input) {
976
+ const orgId = requiredId("orgId", input.orgId);
977
+ const projectId = input.projectId ? requiredId("projectId", input.projectId) : null;
978
+ const occurredAtMs = timestamp(input.occurredAtMs);
979
+ const metadata = jsonObject(input.metadata);
980
+ this.sqlite.query(`
981
+ INSERT INTO _smithers_cp_audit_events (org_id, project_id, actor_id, action, target_type, target_id, occurred_at_ms, metadata_json)
982
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
983
+ `).run(
984
+ orgId,
985
+ projectId,
986
+ input.actorId ? requiredId("actorId", input.actorId) : null,
987
+ nonEmptyString("action", input.action),
988
+ nonEmptyString("targetType", input.targetType),
989
+ input.targetId ? requiredId("targetId", input.targetId) : null,
990
+ occurredAtMs,
991
+ JSON.stringify(metadata),
992
+ );
993
+ const id = this.sqlite.query("SELECT last_insert_rowid() AS id").get().id;
994
+ const row = this.sqlite.query(`
995
+ SELECT id, org_id AS orgId, project_id AS projectId, actor_id AS actorId, action, target_type AS targetType, target_id AS targetId, occurred_at_ms AS occurredAtMs, metadata_json AS metadataJson
996
+ FROM _smithers_cp_audit_events
997
+ WHERE id = ?
998
+ LIMIT 1
999
+ `).get(id);
1000
+ return auditRow(row);
1001
+ }
1002
+
1003
+ /**
1004
+ * @param {{ orgId: string; sinceMs?: number; untilMs?: number; exportedAtMs?: number }} input
1005
+ * @returns {ControlPlaneExport}
1006
+ */
1007
+ exportOrgAudit(input) {
1008
+ const orgId = requiredId("orgId", input.orgId);
1009
+ const org = this.getOrg(orgId);
1010
+ if (!org) {
1011
+ throw new SmithersError("INVALID_INPUT", `Control-plane org not found: ${orgId}`, { orgId });
1012
+ }
1013
+ const sinceMs = input.sinceMs === undefined ? 0 : timestamp(input.sinceMs);
1014
+ const untilMs = input.untilMs === undefined ? Number.MAX_SAFE_INTEGER : timestamp(input.untilMs);
1015
+ const projects = this.sqlite.query(`
1016
+ SELECT org_id AS orgId, project_id AS projectId, slug, name, metadata_json AS metadataJson, created_at_ms AS createdAtMs
1017
+ FROM _smithers_cp_projects
1018
+ WHERE org_id = ?
1019
+ ORDER BY slug
1020
+ `).all(orgId).map(projectRow);
1021
+ const teams = this.sqlite.query(`
1022
+ SELECT org_id AS orgId, team_id AS teamId, slug, name, created_at_ms AS createdAtMs
1023
+ FROM _smithers_cp_teams
1024
+ WHERE org_id = ?
1025
+ ORDER BY slug
1026
+ `).all(orgId).map(teamRow);
1027
+ const billingRaw = this.sqlite.query(`
1028
+ SELECT org_id AS orgId, plan, billing_customer_id AS billingCustomerId, status, updated_at_ms AS updatedAtMs
1029
+ FROM _smithers_cp_billing_accounts
1030
+ WHERE org_id = ?
1031
+ LIMIT 1
1032
+ `).get(orgId);
1033
+ const identityProviders = this.sqlite.query(`
1034
+ SELECT org_id AS orgId, provider_id AS providerId, type, issuer, sso_url AS ssoUrl, certificate_ref AS certificateRef, status, metadata_json AS metadataJson, created_at_ms AS createdAtMs, updated_at_ms AS updatedAtMs
1035
+ FROM _smithers_cp_identity_providers
1036
+ WHERE org_id = ?
1037
+ ORDER BY provider_id
1038
+ `).all(orgId).map(identityProviderRow);
1039
+ const usageLimits = this.sqlite.query(`
1040
+ SELECT org_id AS orgId, project_id AS projectId, metric, unit, period, limit_quantity AS limitQuantity, updated_at_ms AS updatedAtMs
1041
+ FROM _smithers_cp_usage_limits
1042
+ WHERE org_id = ?
1043
+ ORDER BY project_key, metric, unit, period
1044
+ `).all(orgId).map(usageLimitRow);
1045
+ const auditEvents = this.sqlite.query(`
1046
+ SELECT id, org_id AS orgId, project_id AS projectId, actor_id AS actorId, action, target_type AS targetType, target_id AS targetId, occurred_at_ms AS occurredAtMs, metadata_json AS metadataJson
1047
+ FROM _smithers_cp_audit_events
1048
+ WHERE org_id = ? AND occurred_at_ms >= ? AND occurred_at_ms <= ?
1049
+ ORDER BY occurred_at_ms, id
1050
+ `).all(orgId, sinceMs, untilMs).map(auditRow);
1051
+ return {
1052
+ exportedAtMs: timestamp(input.exportedAtMs),
1053
+ org,
1054
+ projects,
1055
+ teams,
1056
+ billing: billingRaw ? billingRow(billingRaw) : null,
1057
+ identityProviders,
1058
+ usage: this.summarizeUsage({ orgId, sinceMs, untilMs }),
1059
+ usageLimits,
1060
+ secretRefs: this.listSecretRefs({ orgId }),
1061
+ auditEvents,
1062
+ };
1063
+ }
1064
+ }