@smithers-orchestrator/control-plane 0.24.2 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/control-plane",
3
- "version": "0.24.2",
3
+ "version": "0.25.1",
4
4
  "description": "Durable organization, project, billing, usage, secret-reference, and audit primitives for hosted Smithers control planes.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -20,7 +20,7 @@
20
20
  "src/"
21
21
  ],
22
22
  "dependencies": {
23
- "@smithers-orchestrator/errors": "0.24.2"
23
+ "@smithers-orchestrator/errors": "0.25.1"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
package/src/index.d.ts CHANGED
@@ -66,12 +66,14 @@ type ControlPlaneUsageEvent = {
66
66
  metadata: Record<string, unknown>;
67
67
  };
68
68
 
69
+ type ControlPlaneUsageLimitPeriod = 'daily' | 'weekly' | 'monthly';
70
+
69
71
  type ControlPlaneUsageLimit = {
70
72
  orgId: string;
71
73
  projectId: string | null;
72
74
  metric: string;
73
75
  unit: string;
74
- period: string;
76
+ period: ControlPlaneUsageLimitPeriod;
75
77
  limitQuantity: number;
76
78
  updatedAtMs: number;
77
79
  };
@@ -141,12 +143,12 @@ declare class ControlPlaneStore {
141
143
  listIdentityProviders(input: { orgId: string; status?: string }): ControlPlaneIdentityProvider[];
142
144
  recordUsage(input: { orgId: string; projectId?: string | null; runId?: string | null; metric: string; quantity: number; unit?: string; observedAtMs?: number; metadata?: Record<string, unknown> }): ControlPlaneUsageEvent;
143
145
  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
+ setUsageLimit(input: { orgId: string; projectId?: string | null; metric: string; unit?: string; period?: ControlPlaneUsageLimitPeriod; limitQuantity: number; updatedAtMs?: number }): ControlPlaneUsageLimit;
147
+ checkUsageLimit(input: { orgId: string; projectId?: string | null; metric: string; unit?: string; period?: ControlPlaneUsageLimitPeriod; sinceMs?: number; untilMs?: number }): ControlPlaneUsageLimitCheck | null;
146
148
  putSecretRef(input: { orgId: string; projectId?: string | null; name: string; provider: string; ref: string; createdBy?: string | null; createdAtMs?: number; rotatedAtMs?: number | null }): ControlPlaneSecretRef;
147
149
  listSecretRefs(input: { orgId: string; projectId?: string | null }): ControlPlaneSecretRef[];
148
150
  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
151
  exportOrgAudit(input: { orgId: string; sinceMs?: number; untilMs?: number; exportedAtMs?: number }): ControlPlaneExport;
150
152
  }
151
153
 
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 };
154
+ 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 ControlPlaneUsageLimitPeriod, type ControlPlaneUsageSummary, ensureControlPlaneTables };
package/src/index.js CHANGED
@@ -19,6 +19,11 @@ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
19
19
 
20
20
  const SLUG_RE = /^(?:[a-z0-9]|[a-z0-9][a-z0-9-]{0,62}[a-z0-9])$/;
21
21
  const ID_RE = /^[A-Za-z0-9:_-]{1,128}$/;
22
+ const USAGE_LIMIT_PERIODS = new Map([
23
+ ["daily", 24 * 60 * 60 * 1000],
24
+ ["weekly", 7 * 24 * 60 * 60 * 1000],
25
+ ["monthly", 30 * 24 * 60 * 60 * 1000],
26
+ ]);
22
27
 
23
28
  /**
24
29
  * @param {ControlPlaneSqlite} sqlite
@@ -285,7 +290,16 @@ function parseJsonObject(value) {
285
290
  if (typeof value !== "string" || value.trim().length === 0) {
286
291
  return {};
287
292
  }
288
- const parsed = JSON.parse(value);
293
+ let parsed;
294
+ try {
295
+ parsed = JSON.parse(value);
296
+ }
297
+ catch (error) {
298
+ console.warn("control-plane: ignoring malformed metadata_json.", {
299
+ error: error instanceof Error ? error.message : String(error),
300
+ });
301
+ return {};
302
+ }
289
303
  return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
290
304
  }
291
305
 
@@ -311,6 +325,17 @@ function quantity(value) {
311
325
  return n;
312
326
  }
313
327
 
328
+ /**
329
+ * @param {unknown} value
330
+ */
331
+ function usageLimitPeriod(value) {
332
+ const period = nonEmptyString("period", value);
333
+ if (!USAGE_LIMIT_PERIODS.has(period)) {
334
+ throw new SmithersError("INVALID_INPUT", "period must be one of: daily, weekly, monthly.", { period });
335
+ }
336
+ return period;
337
+ }
338
+
314
339
  /**
315
340
  * @param {string | null} projectId
316
341
  */
@@ -318,6 +343,33 @@ function projectKey(projectId) {
318
343
  return projectId ?? "__org__";
319
344
  }
320
345
 
346
+ /**
347
+ * @param {unknown} error
348
+ * @param {string} constraint
349
+ */
350
+ function isUniqueConstraintError(error, constraint) {
351
+ return error instanceof Error && error.message.includes(`UNIQUE constraint failed: ${constraint}`);
352
+ }
353
+
354
+ /**
355
+ * @param {unknown} error
356
+ * @param {{ entity: "org" | "team" | "project"; slug: string; orgId?: string }} input
357
+ */
358
+ function throwDuplicateSlugError(error, input) {
359
+ const details = {
360
+ kind: `control-plane.${input.entity}`,
361
+ id: input.slug,
362
+ slug: input.slug,
363
+ ...(input.orgId ? { orgId: input.orgId } : {}),
364
+ };
365
+ throw new SmithersError(
366
+ "DUPLICATE_ID",
367
+ `Duplicate control-plane ${input.entity} slug: ${input.slug}`,
368
+ details,
369
+ { cause: error },
370
+ );
371
+ }
372
+
321
373
  /**
322
374
  * @param {Record<string, unknown>} row
323
375
  * @returns {ControlPlaneOrg}
@@ -421,7 +473,7 @@ function usageLimitRow(row) {
421
473
  projectId: row.projectId === null ? null : String(row.projectId),
422
474
  metric: String(row.metric),
423
475
  unit: String(row.unit),
424
- period: String(row.period),
476
+ period: usageLimitPeriod(row.period),
425
477
  limitQuantity: Number(row.limitQuantity),
426
478
  updatedAtMs: Number(row.updatedAtMs),
427
479
  };
@@ -497,11 +549,20 @@ export class ControlPlaneStore {
497
549
  */
498
550
  createOrg(input) {
499
551
  const orgId = optionalId("orgId", input.orgId);
552
+ const orgSlug = slug("slug", input.slug);
500
553
  const createdAtMs = timestamp(input.createdAtMs);
501
- this.sqlite.query(`
554
+ try {
555
+ this.sqlite.query(`
502
556
  INSERT INTO _smithers_cp_orgs (org_id, slug, name, created_at_ms)
503
557
  VALUES (?, ?, ?, ?)
504
- `).run(orgId, slug("slug", input.slug), nonEmptyString("name", input.name), createdAtMs);
558
+ `).run(orgId, orgSlug, nonEmptyString("name", input.name), createdAtMs);
559
+ }
560
+ catch (error) {
561
+ if (isUniqueConstraintError(error, "_smithers_cp_orgs.slug")) {
562
+ throwDuplicateSlugError(error, { entity: "org", slug: orgSlug });
563
+ }
564
+ throw error;
565
+ }
505
566
  this.recordAuditEvent({
506
567
  orgId,
507
568
  actorId: "system",
@@ -534,11 +595,20 @@ LIMIT 1
534
595
  createTeam(input) {
535
596
  const orgId = requiredId("orgId", input.orgId);
536
597
  const teamId = optionalId("teamId", input.teamId);
598
+ const teamSlug = slug("slug", input.slug);
537
599
  const createdAtMs = timestamp(input.createdAtMs);
538
- this.sqlite.query(`
600
+ try {
601
+ this.sqlite.query(`
539
602
  INSERT INTO _smithers_cp_teams (org_id, team_id, slug, name, created_at_ms)
540
603
  VALUES (?, ?, ?, ?, ?)
541
- `).run(orgId, teamId, slug("slug", input.slug), nonEmptyString("name", input.name), createdAtMs);
604
+ `).run(orgId, teamId, teamSlug, nonEmptyString("name", input.name), createdAtMs);
605
+ }
606
+ catch (error) {
607
+ if (isUniqueConstraintError(error, "_smithers_cp_teams.org_id, _smithers_cp_teams.slug")) {
608
+ throwDuplicateSlugError(error, { entity: "team", slug: teamSlug, orgId });
609
+ }
610
+ throw error;
611
+ }
542
612
  this.recordAuditEvent({
543
613
  orgId,
544
614
  action: "team.create",
@@ -587,12 +657,21 @@ ON CONFLICT(org_id, team_id, user_id) DO UPDATE SET role = excluded.role
587
657
  createProject(input) {
588
658
  const orgId = requiredId("orgId", input.orgId);
589
659
  const projectId = optionalId("projectId", input.projectId);
660
+ const projectSlug = slug("slug", input.slug);
590
661
  const metadata = jsonObject(input.metadata);
591
662
  const createdAtMs = timestamp(input.createdAtMs);
592
- this.sqlite.query(`
663
+ try {
664
+ this.sqlite.query(`
593
665
  INSERT INTO _smithers_cp_projects (org_id, project_id, slug, name, metadata_json, created_at_ms)
594
666
  VALUES (?, ?, ?, ?, ?, ?)
595
- `).run(orgId, projectId, slug("slug", input.slug), nonEmptyString("name", input.name), JSON.stringify(metadata), createdAtMs);
667
+ `).run(orgId, projectId, projectSlug, nonEmptyString("name", input.name), JSON.stringify(metadata), createdAtMs);
668
+ }
669
+ catch (error) {
670
+ if (isUniqueConstraintError(error, "_smithers_cp_projects.org_id, _smithers_cp_projects.slug")) {
671
+ throwDuplicateSlugError(error, { entity: "project", slug: projectSlug, orgId });
672
+ }
673
+ throw error;
674
+ }
596
675
  this.recordAuditEvent({
597
676
  orgId,
598
677
  projectId,
@@ -753,6 +832,9 @@ ORDER BY provider_id
753
832
  recordUsage(input) {
754
833
  const orgId = requiredId("orgId", input.orgId);
755
834
  const projectId = input.projectId ? requiredId("projectId", input.projectId) : null;
835
+ if (projectId) {
836
+ assertProjectExists(this.sqlite, orgId, projectId);
837
+ }
756
838
  const observedAtMs = timestamp(input.observedAtMs);
757
839
  const metadata = jsonObject(input.metadata);
758
840
  this.sqlite.query(`
@@ -816,7 +898,7 @@ ORDER BY metric, unit
816
898
  }
817
899
  const metric = nonEmptyString("metric", input.metric);
818
900
  const unit = nonEmptyString("unit", input.unit ?? "count");
819
- const period = nonEmptyString("period", input.period ?? "monthly");
901
+ const period = usageLimitPeriod(input.period ?? "monthly");
820
902
  const limitValue = quantity(input.limitQuantity);
821
903
  const updatedAtMs = timestamp(input.updatedAtMs);
822
904
  this.sqlite.query(`
@@ -854,7 +936,7 @@ LIMIT 1
854
936
  const projectId = input.projectId ? requiredId("projectId", input.projectId) : null;
855
937
  const metric = nonEmptyString("metric", input.metric);
856
938
  const unit = nonEmptyString("unit", input.unit ?? "count");
857
- const period = nonEmptyString("period", input.period ?? "monthly");
939
+ const period = usageLimitPeriod(input.period ?? "monthly");
858
940
  const limitRowRaw = this.sqlite.query(`
859
941
  SELECT org_id AS orgId, project_id AS projectId, metric, unit, period, limit_quantity AS limitQuantity, updated_at_ms AS updatedAtMs
860
942
  FROM _smithers_cp_usage_limits
@@ -864,8 +946,9 @@ LIMIT 1
864
946
  if (!limitRowRaw) {
865
947
  return null;
866
948
  }
867
- const sinceMs = input.sinceMs === undefined ? 0 : timestamp(input.sinceMs);
868
- const untilMs = input.untilMs === undefined ? Number.MAX_SAFE_INTEGER : timestamp(input.untilMs);
949
+ const untilMs = input.untilMs === undefined ? timestamp(undefined) : timestamp(input.untilMs);
950
+ const periodMs = USAGE_LIMIT_PERIODS.get(period) ?? 0;
951
+ const sinceMs = input.sinceMs === undefined ? Math.max(0, untilMs - periodMs) : timestamp(input.sinceMs);
869
952
  const usageSql = projectId
870
953
  ? `
871
954
  SELECT COALESCE(SUM(quantity), 0) AS usedQuantity
@@ -979,6 +1062,9 @@ ORDER BY name
979
1062
  recordAuditEvent(input) {
980
1063
  const orgId = requiredId("orgId", input.orgId);
981
1064
  const projectId = input.projectId ? requiredId("projectId", input.projectId) : null;
1065
+ if (projectId) {
1066
+ assertProjectExists(this.sqlite, orgId, projectId);
1067
+ }
982
1068
  const occurredAtMs = timestamp(input.occurredAtMs);
983
1069
  const metadata = jsonObject(input.metadata);
984
1070
  this.sqlite.query(`