@smithers-orchestrator/control-plane 0.25.0 → 0.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/index.js +50 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/control-plane",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
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.25.0"
23
+ "@smithers-orchestrator/errors": "0.25.2"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
package/src/index.js CHANGED
@@ -19,6 +19,12 @@ 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
+ // The project_key column folds org-wide (project_id IS NULL) rows under this
23
+ // sentinel. ID_RE happens to accept it, so a project literally named "__org__"
24
+ // would collide with the org-wide scope in the (org_id, project_key, …) primary
25
+ // keys — silently overwriting and cross-leaking org-wide secrets/usage-limits.
26
+ // It is therefore a reserved id, rejected by the id validators below.
27
+ const ORG_WIDE_SENTINEL = "__org__";
22
28
  const USAGE_LIMIT_PERIODS = new Map([
23
29
  ["daily", 24 * 60 * 60 * 1000],
24
30
  ["weekly", 7 * 24 * 60 * 60 * 1000],
@@ -180,15 +186,35 @@ CREATE INDEX IF NOT EXISTS _smithers_cp_audit_org_time_idx
180
186
  /**
181
187
  * @param {ControlPlaneSqlite} sqlite
182
188
  */
189
+ function tableExists(sqlite, name) {
190
+ return Boolean(sqlite.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1").get(name));
191
+ }
192
+
183
193
  function migrateSecretRefsProjectKey(sqlite) {
194
+ const legacyExists = tableExists(sqlite, "_smithers_cp_secret_refs_legacy");
184
195
  const columns = sqlite.query("PRAGMA table_info(_smithers_cp_secret_refs)").all();
185
- if (columns.some((column) => String(column.name) === "project_key")) {
196
+ const hasProjectKey = columns.some((column) => String(column.name) === "project_key");
197
+ // Already migrated and no interrupted migration to finish.
198
+ if (hasProjectKey && !legacyExists) {
186
199
  return;
187
200
  }
188
- sqlite.exec(`
189
- ALTER TABLE _smithers_cp_secret_refs RENAME TO _smithers_cp_secret_refs_legacy;
190
-
191
- CREATE TABLE _smithers_cp_secret_refs (
201
+ // Run the whole migration atomically (SQLite DDL is transactional), AND make
202
+ // it self-healing. The previous version ran RENAME -> CREATE -> INSERT -> DROP
203
+ // as four auto-committed statements: a crash between RENAME and DROP left the
204
+ // data in `_legacy` while `ensureControlPlaneTables` (which already ran its
205
+ // `CREATE TABLE IF NOT EXISTS` for the project_key shape) recreated an EMPTY
206
+ // table. Gating recovery on the column alone then short-circuited and orphaned
207
+ // every secret ref forever. Keying off the legacy table too finishes the copy.
208
+ sqlite.transaction(() => {
209
+ if (!hasProjectKey) {
210
+ // Fresh migration from the pre-project_key schema: move it aside so we
211
+ // can recreate it with the new shape. (A pre-existing `_legacy` here
212
+ // would be a doubly-corrupt state; let the RENAME fail loudly rather
213
+ // than silently guess which table holds the truth.)
214
+ sqlite.exec(`ALTER TABLE _smithers_cp_secret_refs RENAME TO _smithers_cp_secret_refs_legacy;`);
215
+ }
216
+ sqlite.exec(`
217
+ CREATE TABLE IF NOT EXISTS _smithers_cp_secret_refs (
192
218
  org_id TEXT NOT NULL,
193
219
  project_key TEXT NOT NULL,
194
220
  project_id TEXT,
@@ -208,7 +234,7 @@ INSERT OR REPLACE INTO _smithers_cp_secret_refs (
208
234
  )
209
235
  SELECT
210
236
  org_id,
211
- COALESCE(project_id, '__org__') AS project_key,
237
+ COALESCE(project_id, '${ORG_WIDE_SENTINEL}') AS project_key,
212
238
  project_id,
213
239
  name,
214
240
  provider,
@@ -221,6 +247,7 @@ ORDER BY created_at_ms;
221
247
 
222
248
  DROP TABLE _smithers_cp_secret_refs_legacy;
223
249
  `);
250
+ })();
224
251
  }
225
252
 
226
253
  /**
@@ -243,6 +270,9 @@ function optionalId(field, value) {
243
270
  if (!ID_RE.test(id)) {
244
271
  throw new SmithersError("INVALID_INPUT", `${field} must match ${ID_RE}.`, { field });
245
272
  }
273
+ if (id === ORG_WIDE_SENTINEL) {
274
+ throw new SmithersError("INVALID_INPUT", `${field} must not be the reserved value "${ORG_WIDE_SENTINEL}".`, { field });
275
+ }
246
276
  return id;
247
277
  }
248
278
 
@@ -255,6 +285,9 @@ function requiredId(field, value) {
255
285
  if (!ID_RE.test(id)) {
256
286
  throw new SmithersError("INVALID_INPUT", `${field} must match ${ID_RE}.`, { field });
257
287
  }
288
+ if (id === ORG_WIDE_SENTINEL) {
289
+ throw new SmithersError("INVALID_INPUT", `${field} must not be the reserved value "${ORG_WIDE_SENTINEL}".`, { field });
290
+ }
258
291
  return id;
259
292
  }
260
293
 
@@ -340,7 +373,7 @@ function usageLimitPeriod(value) {
340
373
  * @param {string | null} projectId
341
374
  */
342
375
  function projectKey(projectId) {
343
- return projectId ?? "__org__";
376
+ return projectId ?? ORG_WIDE_SENTINEL;
344
377
  }
345
378
 
346
379
  /**
@@ -994,13 +1027,19 @@ WHERE org_id = ? AND metric = ? AND unit = ? AND observed_at_ms >= ? AND observe
994
1027
  const provider = nonEmptyString("provider", input.provider);
995
1028
  const ref = nonEmptyString("ref", input.ref);
996
1029
  const createdBy = input.createdBy ? requiredId("createdBy", input.createdBy) : null;
997
- this.sqlite.query(`
998
- DELETE FROM _smithers_cp_secret_refs
999
- WHERE org_id = ? AND project_key = ? AND name = ?
1000
- `).run(orgId, secretProjectKey, name);
1030
+ // Atomic upsert (matches every other replace-on-write method). The prior
1031
+ // DELETE-then-INSERT was two statements: a crash between them destroyed the
1032
+ // existing secret ref outright.
1001
1033
  this.sqlite.query(`
1002
1034
  INSERT INTO _smithers_cp_secret_refs (org_id, project_key, project_id, name, provider, ref, created_by, created_at_ms, rotated_at_ms)
1003
1035
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1036
+ ON CONFLICT(org_id, project_key, name) DO UPDATE SET
1037
+ project_id = excluded.project_id,
1038
+ provider = excluded.provider,
1039
+ ref = excluded.ref,
1040
+ created_by = excluded.created_by,
1041
+ created_at_ms = excluded.created_at_ms,
1042
+ rotated_at_ms = excluded.rotated_at_ms
1004
1043
  `).run(
1005
1044
  orgId,
1006
1045
  secretProjectKey,