@hasna/skills 0.10.11 → 0.10.14

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.
@@ -1890,6 +1890,7 @@ var {
1890
1890
  // src/server/maintenance-entry.ts
1891
1891
  import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "fs";
1892
1892
  import { createHash as createHash3 } from "crypto";
1893
+ var {SQL: SQL2 } = globalThis.Bun;
1893
1894
 
1894
1895
  // src/server/execution-grant-store.ts
1895
1896
  import { randomUUID } from "crypto";
@@ -1925,27 +1926,6 @@ class MemoryExecutionGrantStore {
1925
1926
  return structuredClone(value);
1926
1927
  }
1927
1928
  }
1928
-
1929
- class SqliteExecutionGrantStore {
1930
- db;
1931
- constructor(db) {
1932
- this.db = db;
1933
- }
1934
- async get(p, id, revision) {
1935
- return document(revision ? this.db.query("SELECT document_json FROM skills_execution_grant_revisions WHERE org_id=? AND profile_id=? AND revision=?").get(p.orgId, id, revision) : this.db.query("SELECT document_json FROM skills_execution_grants WHERE org_id=? AND profile_id=?").get(p.orgId, id));
1936
- }
1937
- async save(p, id, grants, expected) {
1938
- return this.db.transaction(() => {
1939
- const value = next(p, id, grants, expected), json = JSON.stringify(value);
1940
- const row = expected === null ? this.db.query("INSERT INTO skills_execution_grants(org_id,profile_id,revision,document_json) VALUES(?,?,?,?) ON CONFLICT(org_id,profile_id) DO NOTHING RETURNING document_json").get(p.orgId, id, value.revision, json) : this.db.query("UPDATE skills_execution_grants SET revision=?,document_json=? WHERE org_id=? AND profile_id=? AND revision=? RETURNING document_json").get(value.revision, json, p.orgId, id, expected);
1941
- if (!row)
1942
- return null;
1943
- this.db.query("INSERT INTO skills_execution_grant_revisions(org_id,profile_id,revision,document_json) VALUES(?,?,?,?)").run(p.orgId, id, value.revision, json);
1944
- return document(row);
1945
- })();
1946
- }
1947
- }
1948
-
1949
1929
  class PostgresExecutionGrantStore {
1950
1930
  sql;
1951
1931
  constructor(sql) {
@@ -1963,7 +1943,7 @@ class PostgresExecutionGrantStore {
1963
1943
  }
1964
1944
 
1965
1945
  // src/server/store.ts
1966
- import { randomUUID as randomUUID5 } from "crypto";
1946
+ import { randomUUID as randomUUID4 } from "crypto";
1967
1947
 
1968
1948
  // src/server/selection-store.ts
1969
1949
  import { randomUUID as randomUUID2 } from "crypto";
@@ -2037,44 +2017,6 @@ class MemorySkillSelectionStore {
2037
2017
  return structuredClone(next2);
2038
2018
  }
2039
2019
  }
2040
-
2041
- class SqliteSkillSelectionStore {
2042
- db;
2043
- constructor(db) {
2044
- this.db = db;
2045
- }
2046
- async profilesReferencingSkill(p, slug) {
2047
- const rows = this.db.query("SELECT profile_id,selections_json FROM skills_profiles WHERE org_id=?").all(p.orgId);
2048
- return rows.filter((row) => selections(row.selections_json).some((selection) => selection.slug === slug)).map((row) => String(row.profile_id)).sort();
2049
- }
2050
- async getProfile(p, id) {
2051
- const row = this.db.query("SELECT * FROM skills_profiles WHERE org_id=? AND profile_id=?").get(p.orgId, id);
2052
- return row ? profile(row) : null;
2053
- }
2054
- async saveProfile(p, id, selected, expected) {
2055
- return this.db.transaction(() => {
2056
- for (const selection of [...selected].sort((a, b) => a.slug.localeCompare(b.slug))) {
2057
- const skill = this.db.query("SELECT lifecycle FROM skills_registry WHERE org_id=? AND slug=?").get(p.orgId, selection.slug);
2058
- if (skill?.lifecycle === "archived")
2059
- return null;
2060
- }
2061
- const revision = randomUUID2(), timestamp = new Date().toISOString();
2062
- const row = expected === null ? this.db.query("INSERT INTO skills_profiles(org_id,profile_id,revision,selections_json,updated_at) VALUES(?,?,?,?,?) ON CONFLICT(org_id,profile_id) DO NOTHING RETURNING *").get(p.orgId, id, revision, JSON.stringify(selected), timestamp) : this.db.query("UPDATE skills_profiles SET revision=?,selections_json=?,updated_at=? WHERE org_id=? AND profile_id=? AND revision=? RETURNING *").get(revision, JSON.stringify(selected), timestamp, p.orgId, id, expected);
2063
- return row ? profile(row) : null;
2064
- }).immediate();
2065
- }
2066
- async getStationState(p, id) {
2067
- const row = this.db.query("SELECT * FROM skills_station_state WHERE org_id=? AND actor_id=? AND station_id=?").get(p.orgId, p.userId, id);
2068
- return row ? state(row) : null;
2069
- }
2070
- async saveStationState(p, id, input) {
2071
- const row = this.db.query(`INSERT INTO skills_station_state(org_id,actor_id,station_id,profile_id,profile_revision,selections_json,applied_at)
2072
- SELECT ?,?,?,?,?,?,? WHERE EXISTS(SELECT 1 FROM skills_profiles WHERE org_id=? AND profile_id=? AND revision=?)
2073
- ON CONFLICT(org_id,actor_id,station_id) DO UPDATE SET profile_id=excluded.profile_id,profile_revision=excluded.profile_revision,selections_json=excluded.selections_json,applied_at=excluded.applied_at RETURNING *`).get(p.orgId, p.userId, id, input.profileId, input.profileRevision, JSON.stringify(input.selections), new Date().toISOString(), p.orgId, input.profileId, input.profileRevision);
2074
- return row ? state(row) : null;
2075
- }
2076
- }
2077
-
2078
2020
  class PostgresSkillSelectionStore {
2079
2021
  sql;
2080
2022
  constructor(sql) {
@@ -2193,159 +2135,15 @@ function publicPrincipal(partial = {}) {
2193
2135
  };
2194
2136
  }
2195
2137
 
2196
- // src/server/database-url.ts
2197
- import { isAbsolute, join as join2 } from "path";
2198
- import { fileURLToPath } from "url";
2199
-
2200
- // src/lib/config.ts
2201
- import { existsSync as existsSync2, readFileSync, writeFileSync, mkdirSync } from "fs";
2202
-
2203
2138
  // src/lib/retired-settings.ts
2204
2139
  var RETIRED_CONFIG_KEY_REASONS = {
2205
2140
  mode: "Deployment modes were removed: a Skills client either resolves a credential " + "or it does not, and that is the whole of it.",
2206
2141
  apiUrl: "The service address is no longer kept in this app's config file: it is read from " + "HASNA_SKILLS_API_URL, then the macOS Keychain item hasna.credentials.skills.api-url, " + "then ~/.hasna/skills/config/credentials, then the fleet gateway."
2207
2142
  };
2208
-
2209
- // src/lib/app-home.ts
2210
- import { existsSync } from "fs";
2211
- import { homedir } from "os";
2212
- import { join, resolve } from "path";
2213
- import { homedir as pathsResolverHomedir } from "os";
2214
- import { join as pathsResolverJoin } from "path";
2215
- var PATHS_RESOLVER_DATA_ENV = "HASNA_DATA_HOME";
2216
- var PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2217
- function pathsResolverAssertApp(app) {
2218
- if (typeof app !== "string" || app.length === 0) {
2219
- throw new TypeError("paths: app must be a non-empty string");
2220
- }
2221
- if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
2222
- throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
2223
- }
2224
- }
2225
- function pathsResolverDataBaseDir(options) {
2226
- const env = options.env ?? process.env;
2227
- const override = env[PATHS_RESOLVER_DATA_ENV];
2228
- if (typeof override === "string" && override.length > 0)
2229
- return override;
2230
- const home = options.home ?? pathsResolverHomedir();
2231
- const platform = options.platform ?? process.platform;
2232
- return platform === "darwin" ? pathsResolverJoin(home, "Library", "Application Support", "Hasna") : pathsResolverJoin(home, ".local", "share", "hasna");
2233
- }
2234
- function dataDir(options) {
2235
- pathsResolverAssertApp(options.app);
2236
- const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
2237
- return pathsResolverJoin(pathsResolverDataBaseDir(options), appSegment);
2238
- }
2239
- var DATA_DIR_ENV = "HASNA_SKILLS_DIR";
2240
- var HASNA_SKILLS_HOME_ENV = "HASNA_SKILLS_HOME";
2241
- var SKILLS_HOME_ENV = "SKILLS_HOME";
2242
- var DEFAULT_SQLITE_FILENAME = "server.db";
2243
- var GLOBAL_CONFIG_FILENAME = "config.json";
2244
- function effectiveHome() {
2245
- return process.env["HOME"] || process.env["USERPROFILE"] || homedir() || "/tmp";
2246
- }
2247
- function legacyDataRoot() {
2248
- return join(effectiveHome(), ".hasna", "skills");
2249
- }
2250
- function resolverDataRoot(home = effectiveHome(), env) {
2251
- return dataDir({ app: "skills", home, env });
2252
- }
2253
- function adoptResolverDataRoot(resolved, env = process.env) {
2254
- const dataOverride = env.HASNA_DATA_HOME;
2255
- if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
2256
- return true;
2257
- return existsSync(join(resolved, DEFAULT_SQLITE_FILENAME)) || existsSync(join(resolved, GLOBAL_CONFIG_FILENAME));
2258
- }
2259
- function exactDataRoot() {
2260
- for (const key of [DATA_DIR_ENV, HASNA_SKILLS_HOME_ENV, SKILLS_HOME_ENV]) {
2261
- const dir = process.env[key]?.trim();
2262
- if (dir)
2263
- return resolve(dir);
2264
- }
2265
- return;
2266
- }
2267
- function getDataRoot() {
2268
- const exact = exactDataRoot();
2269
- if (exact)
2270
- return exact;
2271
- const resolved = resolverDataRoot();
2272
- return adoptResolverDataRoot(resolved) ? resolve(resolved) : resolve(legacyDataRoot());
2273
- }
2274
- // src/lib/config.ts
2275
- function getDataDir() {
2276
- const root = getDataRoot();
2277
- try {
2278
- mkdirSync(root, { recursive: true });
2279
- } catch {}
2280
- return root;
2281
- }
2282
-
2283
2143
  // src/server/database-url.ts
2284
- var DEFAULT_SQLITE_FILENAME2 = "server.db";
2285
- var SQLITE_MEMORY_PATH = ":memory:";
2286
2144
  var POSTGRES_SCHEMES = new Set(["postgres", "postgresql"]);
2287
2145
  var SQLITE_SCHEMES = new Set(["sqlite", "sqlite3", "file"]);
2288
2146
  var MEMORY_SCHEMES = new Set(["memory"]);
2289
- var SQLITE_EXTENSIONS = [".db", ".sqlite", ".sqlite3", ".db3"];
2290
- function defaultSqlitePath() {
2291
- return join2(getDataDir(), DEFAULT_SQLITE_FILENAME2);
2292
- }
2293
- function resolveDatabaseTarget(raw) {
2294
- const value = raw?.trim();
2295
- if (!value) {
2296
- const path = defaultSqlitePath();
2297
- return { kind: "sqlite", path, durable: true, label: `sqlite (${path})` };
2298
- }
2299
- if (value === SQLITE_MEMORY_PATH) {
2300
- return { kind: "sqlite", path: SQLITE_MEMORY_PATH, durable: false, label: "sqlite (in-memory)" };
2301
- }
2302
- const schemeMatch = value.match(/^([a-z][a-z0-9+.-]+):/i);
2303
- const scheme = schemeMatch?.[1]?.toLowerCase();
2304
- if (scheme && POSTGRES_SCHEMES.has(scheme)) {
2305
- return { kind: "postgres", url: value, durable: true, label: "postgres" };
2306
- }
2307
- if (scheme && MEMORY_SCHEMES.has(scheme)) {
2308
- return { kind: "memory", durable: false, label: "memory (non-durable)" };
2309
- }
2310
- if (scheme && SQLITE_SCHEMES.has(scheme)) {
2311
- const path = sqlitePathFromUrl(value, scheme);
2312
- return path === SQLITE_MEMORY_PATH ? { kind: "sqlite", path, durable: false, label: "sqlite (in-memory)" } : { kind: "sqlite", path, durable: true, label: `sqlite (${path})` };
2313
- }
2314
- if (scheme) {
2315
- throw new Error(`unsupported database scheme "${scheme}:". Supported: postgres://, postgresql://, sqlite:, file:, ` + `an absolute or relative path to a .db/.sqlite file, ":memory:", or "memory:" (non-durable, tests only). ` + `Leave the setting empty to use the default SQLite database at ${defaultSqlitePath()}.`);
2316
- }
2317
- if (looksLikeSqlitePath(value)) {
2318
- const path = isAbsolute(value) ? value : join2(process.cwd(), value);
2319
- return { kind: "sqlite", path, durable: true, label: `sqlite (${path})` };
2320
- }
2321
- throw new Error(`could not resolve a database backend from "${value}". Use postgres://\u2026, sqlite:\u2026, an absolute or ` + `relative path ending in ${SQLITE_EXTENSIONS.join("/")}, ":memory:", or leave it empty for the ` + `default SQLite database at ${defaultSqlitePath()}.`);
2322
- }
2323
- function sqlitePathFromUrl(value, scheme) {
2324
- const rest = value.slice(scheme.length + 1).replace(/[?#].*$/, "");
2325
- if (rest.startsWith(SQLITE_MEMORY_PATH))
2326
- return SQLITE_MEMORY_PATH;
2327
- if (rest === "" || rest === "//") {
2328
- throw new Error(`"${value}" names no database file. Give a path (${scheme}:///var/lib/skills/server.db), ` + `or ":memory:" if a non-durable database is genuinely what you want. ` + "An empty path here usually means an unset variable in a templated URL.");
2329
- }
2330
- if (rest.startsWith("//")) {
2331
- if (!rest.startsWith("///")) {
2332
- const authority = rest.slice(2).split("/")[0];
2333
- throw new Error(`"${value}" has a host ("${authority}") where a path was expected. Remote SQLite files are not a thing; ` + `for an absolute path use ${scheme}:///${rest.slice(2)}, and for a relative one use ${scheme}:${rest.slice(2)}.`);
2334
- }
2335
- if (scheme === "file") {
2336
- try {
2337
- return fileURLToPath(new URL(value));
2338
- } catch {}
2339
- }
2340
- return rest.slice(2).replace(/^\/\/+/, "/");
2341
- }
2342
- return isAbsolute(rest) ? rest : join2(process.cwd(), rest);
2343
- }
2344
- function looksLikeSqlitePath(value) {
2345
- if (value.includes("/"))
2346
- return true;
2347
- return SQLITE_EXTENSIONS.some((extension) => value.toLowerCase().endsWith(extension));
2348
- }
2349
2147
 
2350
2148
  // src/server/rows.ts
2351
2149
  import { randomUUID as randomUUID3 } from "crypto";
@@ -2856,803 +2654,6 @@ function revisionIdOfRecord(record) {
2856
2654
  return revisionIdOf(record);
2857
2655
  }
2858
2656
 
2859
- // src/server/sqlite-store.ts
2860
- import { Database } from "bun:sqlite";
2861
- import { randomUUID as randomUUID4 } from "crypto";
2862
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync } from "fs";
2863
- import { dirname as dirname2, join as join4 } from "path";
2864
-
2865
- // src/server/migrations-dir.ts
2866
- import { existsSync as existsSync3 } from "fs";
2867
- import { dirname, join as join3 } from "path";
2868
- var MIGRATION_DIALECTS = ["postgres", "sqlite"];
2869
- var MAX_WALK_UP = 6;
2870
- function findMigrationsRoot(startDirs = defaultStartDirs()) {
2871
- for (const start of startDirs) {
2872
- let dir = start;
2873
- for (let level = 0;level < MAX_WALK_UP; level += 1) {
2874
- const candidate = join3(dir, "migrations");
2875
- if (MIGRATION_DIALECTS.some((dialect) => existsSync3(join3(candidate, dialect))))
2876
- return candidate;
2877
- const parent = dirname(dir);
2878
- if (parent === dir)
2879
- break;
2880
- dir = parent;
2881
- }
2882
- }
2883
- return null;
2884
- }
2885
- function resolveMigrationsDir(dialect, root = findMigrationsRoot()) {
2886
- if (!root) {
2887
- throw new Error(`could not locate the migrations directory for dialect "${dialect}". ` + `Expected a migrations/${dialect}/ folder alongside the package root.`);
2888
- }
2889
- const dir = join3(root, dialect);
2890
- if (!existsSync3(dir)) {
2891
- throw new Error(`migrations directory not found: ${dir}`);
2892
- }
2893
- return dir;
2894
- }
2895
- function defaultStartDirs() {
2896
- const dirs = [import.meta.dir];
2897
- const cwd = process.cwd();
2898
- if (cwd && cwd !== import.meta.dir)
2899
- dirs.push(cwd);
2900
- return dirs;
2901
- }
2902
-
2903
- // src/server/sqlite-store.ts
2904
- var CLAIM_ATTEMPTS = 8;
2905
- var CLAIMABLE_STATUSES = ["queued", "retrying"];
2906
- var NO_REVISION_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000";
2907
- var LAST_USED_RESOLUTION_MS = 60000;
2908
-
2909
- class SqliteSkillsStore {
2910
- get selectionStore() {
2911
- return new SqliteSkillSelectionStore(this.db);
2912
- }
2913
- get executionGrantStore() {
2914
- return new SqliteExecutionGrantStore(this.db);
2915
- }
2916
- backend;
2917
- db;
2918
- closed = false;
2919
- constructor(path = SQLITE_MEMORY_PATH, options = {}) {
2920
- const inMemory = path === SQLITE_MEMORY_PATH;
2921
- if (!inMemory)
2922
- mkdirSync2(dirname2(path), { recursive: true });
2923
- this.db = openDatabase(path);
2924
- this.db.exec(`PRAGMA busy_timeout = ${Math.max(0, options.busyTimeoutMs ?? 5000)}`);
2925
- if (!inMemory)
2926
- enableWalMode(this.db);
2927
- this.db.exec("PRAGMA foreign_keys = ON");
2928
- this.db.exec("PRAGMA synchronous = NORMAL");
2929
- if (options.migrate !== false) {
2930
- applySqliteMigrations(this.db, options.migrationsDir);
2931
- }
2932
- this.backfillLegacyRevisions();
2933
- this.backend = {
2934
- kind: "sqlite",
2935
- durable: !inMemory,
2936
- label: inMemory ? "sqlite (in-memory)" : `sqlite (${path})`
2937
- };
2938
- }
2939
- backfillLegacyRevisions() {
2940
- const rows = this.all("SELECT * FROM skills_registry WHERE revision_id = ''", []);
2941
- for (const row of rows) {
2942
- const record = rowToSkill(row);
2943
- this.db.run("UPDATE skills_registry SET revision_id = ? WHERE org_id = ? AND slug = ?", [
2944
- revisionIdOfRecord(record),
2945
- record.orgId,
2946
- record.slug
2947
- ]);
2948
- }
2949
- }
2950
- get database() {
2951
- return this.db;
2952
- }
2953
- async close() {
2954
- if (this.closed)
2955
- return;
2956
- this.closed = true;
2957
- this.db.close(false);
2958
- }
2959
- async ensureBootstrapApiKey(token, principal) {
2960
- const resolved = publicPrincipal(principal);
2961
- this.db.transaction(() => {
2962
- this.db.run(`INSERT INTO organizations (id, slug, name) VALUES (?, ?, ?)
2963
- ON CONFLICT (id) DO UPDATE SET slug = excluded.slug, name = excluded.name`, [resolved.orgId, resolved.orgSlug, resolved.orgName]);
2964
- this.db.run(`INSERT INTO users (id, email, name) VALUES (?, ?, ?)
2965
- ON CONFLICT (id) DO UPDATE SET email = excluded.email`, [resolved.userId, resolved.email, resolved.email]);
2966
- this.db.run(`INSERT INTO organization_members (org_id, user_id, role) VALUES (?, ?, ?)
2967
- ON CONFLICT (org_id, user_id) DO UPDATE SET role = excluded.role`, [resolved.orgId, resolved.userId, resolved.role]);
2968
- this.db.run(`INSERT INTO api_keys (id, org_id, user_id, name, key_hash, scopes_json)
2969
- VALUES (?, ?, ?, ?, ?, ?)
2970
- ON CONFLICT (key_hash) DO NOTHING`, [resolved.apiKeyId, resolved.orgId, resolved.userId, "bootstrap", hashApiKey(token), JSON.stringify(resolved.scopes)]);
2971
- })();
2972
- }
2973
- async authenticateApiKeyHash(hash) {
2974
- const row = this.get(`SELECT k.id AS api_key_id, k.scopes_json, k.last_used_at, o.id AS org_id, o.slug AS org_slug, o.name AS org_name,
2975
- u.id AS user_id, u.email, m.role
2976
- FROM api_keys k
2977
- JOIN organizations o ON o.id = k.org_id
2978
- JOIN users u ON u.id = k.user_id
2979
- LEFT JOIN organization_members m ON m.org_id = k.org_id AND m.user_id = k.user_id
2980
- WHERE k.key_hash = ? AND k.revoked_at IS NULL
2981
- LIMIT 1`, [hash]);
2982
- if (!row)
2983
- return null;
2984
- const lastUsed = typeof row.last_used_at === "string" ? Date.parse(row.last_used_at) : Number.NaN;
2985
- if (!Number.isFinite(lastUsed) || Date.now() - lastUsed >= LAST_USED_RESOLUTION_MS) {
2986
- this.db.run("UPDATE api_keys SET last_used_at = ? WHERE id = ?", [nowIso(), String(row.api_key_id)]);
2987
- }
2988
- return {
2989
- apiKeyId: String(row.api_key_id),
2990
- orgId: String(row.org_id),
2991
- orgSlug: String(row.org_slug),
2992
- orgName: String(row.org_name),
2993
- userId: String(row.user_id),
2994
- email: String(row.email),
2995
- role: typeof row.role === "string" ? row.role : "member",
2996
- scopes: parseScopes(row.scopes_json)
2997
- };
2998
- }
2999
- async updateApiKeyScopes(actor, keyId, expectedScopes, addScopes) {
3000
- const row = this.get("SELECT scopes_json FROM api_keys WHERE id = ? AND org_id = ? AND revoked_at IS NULL LIMIT 1", [keyId, actor.orgId]);
3001
- if (!row)
3002
- return { kind: "not_found" };
3003
- const current = parseScopes(row.scopes_json);
3004
- if (current.length !== expectedScopes.length || current.some((scope, index) => scope !== expectedScopes[index])) {
3005
- return { kind: "stale", scopes: current };
3006
- }
3007
- const scopes = [...current, ...addScopes.filter((scope) => !current.includes(scope))];
3008
- const tx = this.db.transaction(() => {
3009
- const changed = this.db.run("UPDATE api_keys SET scopes_json = ? WHERE id = ? AND org_id = ? AND revoked_at IS NULL AND scopes_json = ?", [JSON.stringify(scopes), keyId, actor.orgId, JSON.stringify(expectedScopes)]);
3010
- if (changed.changes !== 1)
3011
- return false;
3012
- this.db.run("INSERT INTO skills_audit_events (org_id, user_id, api_key_id, action, target_type, target_id, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?)", [actor.orgId, actor.userId, actor.apiKeyId, "api_key_scopes_added", "api_key", keyId, JSON.stringify({ added: addScopes, scopes })]);
3013
- return true;
3014
- });
3015
- if (!tx()) {
3016
- const latest = this.get("SELECT scopes_json FROM api_keys WHERE id = ? AND org_id = ? AND revoked_at IS NULL LIMIT 1", [keyId, actor.orgId]);
3017
- return { kind: "stale", scopes: latest ? parseScopes(latest.scopes_json) : current };
3018
- }
3019
- return { kind: "updated", scopes };
3020
- }
3021
- async enrollPublishScopeByOperator(input) {
3022
- if (!validOperatorScopeEnrollmentInput(input))
3023
- return { kind: "invalid" };
3024
- if (input.expectedScopes.includes("skills:publish"))
3025
- return { kind: "stale", scopes: input.expectedScopes };
3026
- const operation = this.get("SELECT org_id, target_id FROM skills_audit_events WHERE action = ? AND operator_operation_id = ? LIMIT 1", ["api_key_scopes_added", input.operationId]);
3027
- if (operation && (String(operation.org_id) !== input.orgId || String(operation.target_id) !== input.keyId))
3028
- return { kind: "target_mismatch" };
3029
- const row = this.get("SELECT id, org_id, name, scopes_json FROM api_keys WHERE id = ? AND revoked_at IS NULL LIMIT 1", [input.keyId]);
3030
- if (!row)
3031
- return { kind: "not_found" };
3032
- if (String(row.org_id) !== input.orgId)
3033
- return { kind: "target_mismatch" };
3034
- const current = parseScopes(row.scopes_json);
3035
- const prior = this.get("SELECT metadata_json FROM skills_audit_events WHERE action = ? AND target_type = ? AND target_id = ? AND json_extract(metadata_json, '$.operator_operation_id') = ? LIMIT 1", ["api_key_scopes_added", "api_key", input.keyId, input.operationId]);
3036
- if (prior) {
3037
- try {
3038
- if (JSON.parse(String(prior.metadata_json)).target_manifest_digest !== input.manifestDigest)
3039
- return { kind: "target_mismatch" };
3040
- } catch {
3041
- return { kind: "target_mismatch" };
3042
- }
3043
- return current.includes("skills:publish") ? { kind: "already_applied", scopes: current } : { kind: "stale", scopes: current };
3044
- }
3045
- if (current.length !== input.expectedScopes.length || current.some((scope, index) => scope !== input.expectedScopes[index]))
3046
- return { kind: "stale", scopes: current };
3047
- const scopes = [...current, "skills:publish"];
3048
- const tx = this.db.transaction(() => {
3049
- const changed = this.db.run("UPDATE api_keys SET scopes_json = ? WHERE id = ? AND org_id = ? AND revoked_at IS NULL AND scopes_json = ?", [JSON.stringify(scopes), input.keyId, input.orgId, JSON.stringify(input.expectedScopes)]);
3050
- if (changed.changes !== 1)
3051
- return false;
3052
- this.db.run("INSERT INTO skills_audit_events (org_id, user_id, api_key_id, action, target_type, target_id, operator_operation_id, metadata_json) VALUES (?, NULL, NULL, ?, ?, ?, ?, ?)", [input.orgId, "api_key_scopes_added", "api_key", input.keyId, input.operationId, JSON.stringify({ added: ["skills:publish"], scopes, operator_operation_id: input.operationId, operator_job_id: input.operatorJobId, operator_task_arn: input.operatorTaskArn, target_manifest_digest: input.manifestDigest, station_id: input.stationId })]);
3053
- return true;
3054
- });
3055
- if (!tx()) {
3056
- const latest = this.get("SELECT scopes_json FROM api_keys WHERE id = ? AND org_id = ? AND revoked_at IS NULL LIMIT 1", [input.keyId, input.orgId]);
3057
- return { kind: "stale", scopes: latest ? parseScopes(latest.scopes_json) : current };
3058
- }
3059
- return { kind: "updated", scopes };
3060
- }
3061
- async inspectOperatorScopeTarget(keyId, orgId) {
3062
- const row = this.get("SELECT org_id, scopes_json FROM api_keys WHERE id = ? AND revoked_at IS NULL LIMIT 1", [keyId]);
3063
- if (!row)
3064
- return { kind: "not_found" };
3065
- if (String(row.org_id) !== orgId)
3066
- return { kind: "target_mismatch" };
3067
- return { kind: "found", scopes: parseScopes(row.scopes_json) };
3068
- }
3069
- async createRun(input) {
3070
- if (input.idempotencyKey) {
3071
- const existing = this.get("SELECT * FROM skills_runs WHERE org_id = ? AND idempotency_key = ? LIMIT 1", [input.principal.orgId, input.idempotencyKey]);
3072
- if (existing)
3073
- return rowToRun(existing);
3074
- }
3075
- const row = this.get(`INSERT INTO skills_runs (id, org_id, user_id, skill_slug, requested_slug, status, input_json, args_json, idempotency_key, correlation_id, created_at)
3076
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3077
- RETURNING *`, [
3078
- runId(),
3079
- input.principal.orgId,
3080
- input.principal.userId,
3081
- input.slug,
3082
- input.slug,
3083
- "queued",
3084
- JSON.stringify(input.input),
3085
- JSON.stringify(input.args),
3086
- input.idempotencyKey ?? null,
3087
- randomUUID4(),
3088
- nowIso()
3089
- ]);
3090
- return rowToRun(row);
3091
- }
3092
- async listRuns(principal, limit) {
3093
- return this.all("SELECT * FROM skills_runs WHERE org_id = ? ORDER BY created_at DESC, rowid DESC LIMIT ?", [principal.orgId, normalizeLimit(limit)]).map(rowToRun);
3094
- }
3095
- async getRun(principal, id) {
3096
- const row = this.get("SELECT * FROM skills_runs WHERE id = ? AND org_id = ? LIMIT 1", [id, principal.orgId]);
3097
- return row ? rowToRun(row) : null;
3098
- }
3099
- async claimNextRun(input) {
3100
- const lost = new Set;
3101
- for (let attempt = 0;attempt < CLAIM_ATTEMPTS; attempt += 1) {
3102
- const claimed = this.claimOnce(input.workerId, lost);
3103
- if (claimed === "empty")
3104
- return null;
3105
- if (claimed)
3106
- return claimed;
3107
- }
3108
- return null;
3109
- }
3110
- claimOnce(workerId, lost) {
3111
- this.db.exec("BEGIN IMMEDIATE");
3112
- try {
3113
- const excluded = [...lost];
3114
- const candidate = this.get(`SELECT id FROM skills_runs
3115
- WHERE status IN (${CLAIMABLE_STATUSES.map(() => "?").join(", ")})
3116
- ${excluded.length ? `AND id NOT IN (${excluded.map(() => "?").join(", ")})` : ""}
3117
- ORDER BY created_at ASC, rowid ASC
3118
- LIMIT 1`, [...CLAIMABLE_STATUSES, ...excluded]);
3119
- if (!candidate) {
3120
- this.db.exec("COMMIT");
3121
- return "empty";
3122
- }
3123
- const id = String(candidate.id);
3124
- const now = nowIso();
3125
- const result = this.db.run(`UPDATE skills_runs
3126
- SET status = 'running', started_at = COALESCE(started_at, ?), locked_by = ?, locked_at = ?,
3127
- lease_generation = lease_generation + 1
3128
- WHERE id = ? AND status IN (${CLAIMABLE_STATUSES.map(() => "?").join(", ")})`, [now, workerId, now, id, ...CLAIMABLE_STATUSES]);
3129
- if (result.changes !== 1) {
3130
- this.db.exec("ROLLBACK");
3131
- lost.add(id);
3132
- return null;
3133
- }
3134
- const row = this.get("SELECT * FROM skills_runs WHERE id = ? LIMIT 1", [id]);
3135
- if (!row) {
3136
- this.db.exec("ROLLBACK");
3137
- throw new Error(`claimed run ${id} vanished within its own transaction; refusing to report a lost claim as an empty queue`);
3138
- }
3139
- this.db.exec("COMMIT");
3140
- return rowToRun(row);
3141
- } catch (error) {
3142
- this.rollbackQuietly();
3143
- throw error;
3144
- }
3145
- }
3146
- async updateRun(id, patch) {
3147
- const current = this.get("SELECT * FROM skills_runs WHERE id = ? LIMIT 1", [id]);
3148
- if (!current)
3149
- return null;
3150
- const run = { ...rowToRun(current), ...patch };
3151
- const row = this.get(`UPDATE skills_runs
3152
- SET status = ?, output_type = ?, output_preview = ?, error_code = ?, error_message = ?, started_at = ?, completed_at = ?
3153
- WHERE id = ?
3154
- RETURNING *`, [
3155
- run.status,
3156
- run.outputType ?? null,
3157
- run.outputPreview ?? null,
3158
- run.errorCode ?? null,
3159
- run.errorMessage ?? null,
3160
- run.startedAt ?? null,
3161
- run.completedAt ?? null,
3162
- id
3163
- ]);
3164
- return row ? rowToRun(row) : null;
3165
- }
3166
- async transitionRun(id, patch, expectedGeneration) {
3167
- const current = this.get("SELECT * FROM skills_runs WHERE id = ? LIMIT 1", [id]);
3168
- if (!current)
3169
- return null;
3170
- const stored = rowToRun(current);
3171
- if (stored.leaseGeneration !== expectedGeneration) {
3172
- throw new StaleLeaseGenerationError(id, expectedGeneration, stored.leaseGeneration, stored.status);
3173
- }
3174
- const run = { ...stored, ...patch };
3175
- const row = this.get(`UPDATE skills_runs
3176
- SET status = ?, output_type = ?, output_preview = ?, error_code = ?, error_message = ?, started_at = ?, completed_at = ?,
3177
- lease_generation = ?
3178
- WHERE id = ? AND lease_generation = ?
3179
- RETURNING *`, [
3180
- run.status,
3181
- run.outputType ?? null,
3182
- run.outputPreview ?? null,
3183
- run.errorCode ?? null,
3184
- run.errorMessage ?? null,
3185
- run.startedAt ?? null,
3186
- run.completedAt ?? null,
3187
- run.leaseGeneration,
3188
- id,
3189
- expectedGeneration
3190
- ]);
3191
- return row ? rowToRun(row) : null;
3192
- }
3193
- async appendLog(id, orgId, level, message) {
3194
- const row = this.get(`INSERT INTO skills_run_logs (run_id, org_id, sequence, level, message, created_at)
3195
- VALUES (?, ?, (SELECT COALESCE(MAX(sequence), 0) + 1 FROM skills_run_logs WHERE run_id = ?), ?, ?, ?)
3196
- RETURNING *`, [id, orgId, id, level, message, nowIso()]);
3197
- return rowToLog(row);
3198
- }
3199
- async listLogs(principal, id) {
3200
- return this.all(`SELECT l.* FROM skills_run_logs l
3201
- JOIN skills_runs r ON r.id = l.run_id AND r.org_id = ?
3202
- WHERE l.run_id = ?
3203
- ORDER BY l.sequence ASC`, [principal.orgId, id]).map(rowToLog);
3204
- }
3205
- async addArtifact(artifact) {
3206
- const row = this.get(`INSERT INTO skills_artifacts (id, run_id, org_id, file_name, relative_path, content_type, byte_size, sha256, storage_kind, storage_key, body_text, visibility, expires_at, created_at)
3207
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3208
- RETURNING *`, [
3209
- artifact.id,
3210
- artifact.runId,
3211
- artifact.orgId,
3212
- artifact.fileName,
3213
- artifact.relativePath,
3214
- artifact.contentType,
3215
- artifact.byteSize,
3216
- artifact.sha256,
3217
- artifact.storageKind,
3218
- artifact.storageKey ?? null,
3219
- artifact.bodyText ?? null,
3220
- artifact.visibility,
3221
- artifact.expiresAt ?? null,
3222
- nowIso()
3223
- ]);
3224
- return rowToArtifact(row);
3225
- }
3226
- async listArtifacts(principal, id) {
3227
- return this.all(`SELECT a.* FROM skills_artifacts a
3228
- JOIN skills_runs r ON r.id = a.run_id AND r.org_id = ?
3229
- WHERE a.run_id = ?
3230
- ORDER BY a.created_at ASC, a.rowid ASC`, [principal.orgId, id]).map(rowToArtifact);
3231
- }
3232
- async getArtifact(principal, id, artifact) {
3233
- const row = this.get(`SELECT a.* FROM skills_artifacts a
3234
- JOIN skills_runs r ON r.id = a.run_id AND r.org_id = ?
3235
- WHERE a.run_id = ? AND a.id = ?
3236
- LIMIT 1`, [principal.orgId, id, artifact]);
3237
- return row ? rowToArtifact(row) : null;
3238
- }
3239
- async publishSkill(input) {
3240
- const orgId = input.principal.orgId;
3241
- const now = nowIso();
3242
- return this.db.transaction(() => {
3243
- const previous = this.get("SELECT revision_id, revision_number, bundle_sha256, bundle_byte_size, skill_md, tombstoned_at, source FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
3244
- const previousSha = typeof previous?.bundle_sha256 === "string" ? previous.bundle_sha256 : null;
3245
- const previousRevisionId = typeof previous?.revision_id === "string" && previous.revision_id ? previous.revision_id : null;
3246
- const tombstoned = previous?.tombstoned_at != null;
3247
- if (input.seedBundledOnly && input.expectedRevisionId && !previous) {
3248
- throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, null);
3249
- }
3250
- if (input.seedBundledOnly && previous && (tombstoned || previous.source !== "bundled")) {
3251
- throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
3252
- }
3253
- const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : typeof previous?.skill_md === "string" ? previous.skill_md : null;
3254
- if (previous && !tombstoned && input.expectedRevisionId !== previousRevisionId) {
3255
- throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
3256
- }
3257
- const carriedSha = input.bundle?.sha256 ?? previousSha;
3258
- if (input.version && carriedSha) {
3259
- const existingVersion = this.get("SELECT bundle_sha256 FROM skills_versions WHERE org_id = ? AND slug = ? AND version = ? LIMIT 1", [orgId, input.slug, input.version]);
3260
- const existingSha = typeof existingVersion?.bundle_sha256 === "string" ? existingVersion.bundle_sha256 : null;
3261
- if (existingSha && existingSha !== carriedSha) {
3262
- throw new SkillVersionExistsError(input.slug, input.version, existingSha, carriedSha);
3263
- }
3264
- }
3265
- const carriedSize = input.bundle?.byteSize ?? (previous?.bundle_byte_size == null ? null : Number(previous.bundle_byte_size));
3266
- const revisionId = revisionIdOfRecord({
3267
- slug: input.slug,
3268
- displayName: input.displayName,
3269
- description: input.description,
3270
- category: input.category,
3271
- tags: input.tags,
3272
- source: input.source,
3273
- kind: input.kind,
3274
- ...input.version ? { version: input.version } : {},
3275
- ...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
3276
- ...carriedSha ? { bundleSha256: carriedSha } : {},
3277
- ...carriedSize === null || carriedSize === undefined ? {} : { bundleByteSize: carriedSize }
3278
- });
3279
- if (input.bundle) {
3280
- this.db.run(`INSERT INTO skills_bundles (org_id, sha256, byte_size, content_type, storage_kind, storage_key, body_blob, created_at)
3281
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
3282
- ON CONFLICT (org_id, sha256) DO UPDATE SET
3283
- byte_size = excluded.byte_size,
3284
- content_type = excluded.content_type,
3285
- storage_kind = excluded.storage_kind,
3286
- storage_key = excluded.storage_key,
3287
- body_blob = excluded.body_blob`, [
3288
- orgId,
3289
- input.bundle.sha256,
3290
- input.bundle.byteSize,
3291
- input.bundle.contentType,
3292
- input.bundle.storageKind,
3293
- input.bundle.storageKey ?? null,
3294
- input.bundle.bytes ?? null,
3295
- now
3296
- ]);
3297
- }
3298
- const row = this.get(`INSERT INTO skills_registry (org_id, slug, display_name, description, category, tags_json, source, kind, version, skill_md,
3299
- bundle_sha256, bundle_byte_size, published_by_user_id, revision_id, revision_number, created_at, updated_at)
3300
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
3301
- ON CONFLICT (org_id, slug) DO UPDATE SET
3302
- display_name = excluded.display_name,
3303
- description = excluded.description,
3304
- category = excluded.category,
3305
- tags_json = excluded.tags_json,
3306
- source = excluded.source,
3307
- kind = excluded.kind,
3308
- version = excluded.version,
3309
- skill_md = excluded.skill_md,
3310
- -- COALESCE, not a straight assignment. A publish that carries no bundle is a
3311
- -- metadata update, not an instruction to discard the stored one: the plain
3312
- -- assignment nulled bundle_sha256 and then handed the old digest to orphan
3313
- -- collection, so re-publishing metadata over an existing skill deleted its
3314
- -- tarball and left every client with a 404.
3315
- bundle_sha256 = COALESCE(excluded.bundle_sha256, skills_registry.bundle_sha256),
3316
- bundle_byte_size = COALESCE(excluded.bundle_byte_size, skills_registry.bundle_byte_size),
3317
- published_by_user_id = excluded.published_by_user_id,
3318
- revision_id = excluded.revision_id,
3319
- -- Current + 1, not the inserted 1: on the update path the ACTUAL row's
3320
- -- counter is the truth (it may have advanced since the pre-read, which is
3321
- -- exactly what the WHERE guard below detects).
3322
- revision_number = skills_registry.revision_number + 1,
3323
- tombstoned_at = NULL,
3324
- tombstone_purge_after = NULL,
3325
- updated_at = excluded.updated_at
3326
- WHERE (skills_registry.tombstoned_at IS NOT NULL OR skills_registry.revision_id = ?)
3327
- AND (? = 0 OR (skills_registry.tombstoned_at IS NULL AND skills_registry.source = 'bundled'
3328
- AND skills_registry.revision_id = ?))
3329
- RETURNING *`, [
3330
- orgId,
3331
- input.slug,
3332
- input.displayName,
3333
- input.description,
3334
- input.category,
3335
- JSON.stringify(input.tags),
3336
- input.source,
3337
- input.kind,
3338
- input.version ?? null,
3339
- carriedSkillMd,
3340
- input.bundle?.sha256 ?? null,
3341
- input.bundle?.byteSize ?? null,
3342
- input.principal.userId,
3343
- revisionId,
3344
- now,
3345
- now,
3346
- input.expectedRevisionId ?? NO_REVISION_SENTINEL,
3347
- input.seedBundledOnly ? 1 : 0,
3348
- input.expectedRevisionId ?? NO_REVISION_SENTINEL
3349
- ]);
3350
- if (!row) {
3351
- const current = this.get("SELECT revision_id FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
3352
- const currentId = typeof current?.revision_id === "string" ? current.revision_id : null;
3353
- throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, currentId);
3354
- }
3355
- if (input.version && carriedSha) {
3356
- this.db.run(`INSERT OR IGNORE INTO skills_versions (org_id, slug, version, bundle_sha256, bundle_byte_size, storage_kind, storage_key, manifest_json, published_by_user_id, created_at)
3357
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
3358
- orgId,
3359
- input.slug,
3360
- input.version,
3361
- carriedSha,
3362
- carriedSize ?? 0,
3363
- input.versionStorage?.storageKind ?? "db",
3364
- input.versionStorage?.storageKey ?? null,
3365
- JSON.stringify(input.versionManifest ?? {}),
3366
- input.principal.userId ?? null,
3367
- now
3368
- ]);
3369
- }
3370
- if (previousSha && input.bundle && previousSha !== input.bundle.sha256)
3371
- this.collectOrphanBundle(orgId, previousSha);
3372
- this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
3373
- const insertTag = this.db.prepare("INSERT OR IGNORE INTO skills_tags (org_id, slug, tag) VALUES (?, ?, ?)");
3374
- for (const tag of input.tags) {
3375
- if (!tag.trim())
3376
- continue;
3377
- insertTag.run(orgId, input.slug, tag);
3378
- }
3379
- return rowToSkill(row);
3380
- })();
3381
- }
3382
- async listSkills(principal) {
3383
- await this.purgeExpiredTombstones(principal);
3384
- return this.all("SELECT * FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NULL ORDER BY slug ASC", [principal.orgId]).map(rowToSkill);
3385
- }
3386
- async getSkill(principal, slug) {
3387
- const row = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
3388
- return row ? rowToSkill(row) : null;
3389
- }
3390
- async setSkillLifecycle(principal, slug, patch, expectedRevisionId) {
3391
- return this.db.transaction(() => {
3392
- const rowCurrent = this.get("SELECT * FROM skills_registry WHERE org_id=? AND slug=? LIMIT 1", [principal.orgId, slug]);
3393
- const current = rowCurrent ? rowToSkill(rowCurrent) : null;
3394
- if (!current || current.tombstonedAt)
3395
- return null;
3396
- if (expectedRevisionId !== current.revisionId)
3397
- throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
3398
- if (patch.lifecycle === "archived" && current.lifecycle !== "archived") {
3399
- const profiles = this.db.query("SELECT profile_id,selections_json FROM skills_profiles WHERE org_id=?").all(principal.orgId);
3400
- const referencing = profiles.filter((profile2) => {
3401
- const selections2 = typeof profile2.selections_json === "string" ? JSON.parse(profile2.selections_json) : profile2.selections_json;
3402
- return Array.isArray(selections2) && selections2.some((selection) => selection?.slug === slug);
3403
- }).map((profile2) => String(profile2.profile_id)).sort();
3404
- if (referencing.length)
3405
- throw new SkillLifecycleConflictError(slug, referencing);
3406
- }
3407
- const next2 = { ...current, lifecycle: patch.lifecycle, updatedAt: nowIso(), ...patch.lifecycle === "archived" ? { archivedAt: current.archivedAt ?? nowIso(), ...patch.reason ? { archiveReason: patch.reason } : {}, ...patch.replacementSlug ? { replacementSlug: patch.replacementSlug } : {} } : {} };
3408
- if (patch.lifecycle === "active") {
3409
- delete next2.archivedAt;
3410
- delete next2.archiveReason;
3411
- delete next2.replacementSlug;
3412
- }
3413
- const row = this.get("UPDATE skills_registry SET lifecycle=?, archived_at=?, archive_reason=?, replacement_slug=?, revision_id=?, revision_number=revision_number+1, updated_at=? WHERE org_id=? AND slug=? AND tombstoned_at IS NULL AND revision_id=? RETURNING *", [next2.lifecycle, next2.archivedAt ?? null, next2.archiveReason ?? null, next2.replacementSlug ?? null, revisionIdOfRecord(next2), next2.updatedAt, principal.orgId, slug, current.revisionId]);
3414
- if (!row)
3415
- throw new SkillRevisionConflictError(slug, expectedRevisionId, this.getSkillSync(principal, slug)?.revisionId ?? null);
3416
- return rowToSkill(row);
3417
- }).immediate();
3418
- }
3419
- getSkillSync(principal, slug) {
3420
- const row = this.get("SELECT * FROM skills_registry WHERE org_id=? AND slug=? LIMIT 1", [principal.orgId, slug]);
3421
- return row ? rowToSkill(row) : null;
3422
- }
3423
- async updateSkill(principal, slug, patch, expectedRevisionId) {
3424
- const current = await this.getSkill(principal, slug);
3425
- if (!current || current.tombstonedAt)
3426
- return null;
3427
- if (expectedRevisionId !== current.revisionId) {
3428
- throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
3429
- }
3430
- const next2 = { ...current, ...patch };
3431
- return this.db.transaction(() => {
3432
- const revisionId = revisionIdOfRecord(next2);
3433
- const row = this.get(`UPDATE skills_registry
3434
- SET display_name = ?, description = ?, category = ?, tags_json = ?, kind = ?, version = ?, skill_md = ?,
3435
- revision_id = ?, revision_number = revision_number + 1, updated_at = ?
3436
- WHERE org_id = ? AND slug = ? AND tombstoned_at IS NULL AND revision_id = ?
3437
- RETURNING *`, [
3438
- next2.displayName,
3439
- next2.description,
3440
- next2.category,
3441
- JSON.stringify(next2.tags),
3442
- next2.kind,
3443
- next2.version ?? null,
3444
- next2.skillMd ?? null,
3445
- revisionId,
3446
- nowIso(),
3447
- principal.orgId,
3448
- slug,
3449
- current.revisionId
3450
- ]);
3451
- if (!row) {
3452
- const nowRow = this.get("SELECT revision_id, tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [
3453
- principal.orgId,
3454
- slug
3455
- ]);
3456
- if (nowRow && nowRow.tombstoned_at == null) {
3457
- const currentId = typeof nowRow.revision_id === "string" ? nowRow.revision_id : null;
3458
- throw new SkillRevisionConflictError(slug, expectedRevisionId, currentId);
3459
- }
3460
- return null;
3461
- }
3462
- this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
3463
- const insertTag = this.db.prepare("INSERT OR IGNORE INTO skills_tags (org_id, slug, tag) VALUES (?, ?, ?)");
3464
- for (const tag of next2.tags) {
3465
- if (!tag.trim())
3466
- continue;
3467
- insertTag.run(principal.orgId, slug, tag);
3468
- }
3469
- return rowToSkill(row);
3470
- })();
3471
- }
3472
- async deleteSkill(principal, slug, tombstoneWindowMs) {
3473
- return this.db.transaction(() => {
3474
- const existing = this.get("SELECT tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
3475
- if (!existing)
3476
- return null;
3477
- if (existing.tombstoned_at != null) {
3478
- const row2 = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
3479
- return rowToSkill(row2);
3480
- }
3481
- const tombstonedAt = nowIso();
3482
- const purgeAfter = new Date(Date.now() + tombstoneWindowMs).toISOString();
3483
- const row = this.get(`UPDATE skills_registry
3484
- SET tombstoned_at = ?, tombstone_purge_after = ?, updated_at = ?
3485
- WHERE org_id = ? AND slug = ?
3486
- RETURNING *`, [tombstonedAt, purgeAfter, tombstonedAt, principal.orgId, slug]);
3487
- return rowToSkill(row);
3488
- })();
3489
- }
3490
- async purgeExpiredTombstones(principal) {
3491
- return this.db.transaction(() => {
3492
- const now = nowIso();
3493
- const expired = this.all("SELECT * FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NOT NULL AND tombstone_purge_after <= ?", [principal.orgId, now]);
3494
- const purged = [];
3495
- for (const row of expired) {
3496
- const record = rowToSkill(row);
3497
- this.db.run("DELETE FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, record.slug]);
3498
- this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [principal.orgId, record.slug]);
3499
- if (record.bundleSha256)
3500
- this.collectOrphanBundle(principal.orgId, record.bundleSha256);
3501
- purged.push(record);
3502
- }
3503
- return purged;
3504
- })();
3505
- }
3506
- async getSkillBundle(principal, sha256) {
3507
- const row = this.get("SELECT * FROM skills_bundles WHERE org_id = ? AND sha256 = ? LIMIT 1", [principal.orgId, sha256]);
3508
- return row ? rowToSkillBundle(row) : null;
3509
- }
3510
- async listSkillVersions(principal, slug) {
3511
- return this.all("SELECT * FROM skills_versions WHERE org_id = ? AND slug = ? ORDER BY created_at DESC, version DESC", [principal.orgId, slug]).map(rowToSkillVersion);
3512
- }
3513
- async getSkillVersion(principal, slug, version) {
3514
- const row = this.get("SELECT * FROM skills_versions WHERE org_id = ? AND slug = ? AND version = ? LIMIT 1", [principal.orgId, slug, version]);
3515
- return row ? rowToSkillVersion(row) : null;
3516
- }
3517
- async getPublishedSelectionStates(principal, selections2) {
3518
- if (selections2.length > MAX_PROFILE_SELECTIONS)
3519
- throw new RangeError("Published selection lookup exceeds the profile selection limit");
3520
- if (!selections2.length)
3521
- return [];
3522
- const requested = JSON.stringify(selections2.map(({ slug, version }) => ({ slug, version })));
3523
- return this.all(`SELECT json_extract(q.value, '$.slug') AS slug, json_extract(q.value, '$.version') AS version,
3524
- r.slug IS NOT NULL AS skill_exists, r.lifecycle, r.tombstoned_at,
3525
- v.bundle_sha256, b.sha256 IS NOT NULL AS bundle_available
3526
- FROM json_each(?) q
3527
- LEFT JOIN skills_registry r ON r.org_id = ? AND r.slug = json_extract(q.value, '$.slug')
3528
- LEFT JOIN skills_versions v ON v.org_id = ?
3529
- AND v.slug = json_extract(q.value, '$.slug') AND v.version = json_extract(q.value, '$.version')
3530
- LEFT JOIN skills_bundles b ON b.org_id = ? AND b.sha256 = v.bundle_sha256
3531
- ORDER BY CAST(q.key AS INTEGER)`, [requested, principal.orgId, principal.orgId, principal.orgId]).map(rowToPublishedSelectionState);
3532
- }
3533
- async pinSkill(principal, slug, metadata = {}) {
3534
- const row = this.get(`INSERT INTO skills_pins (org_id, principal, slug, pinned_at, metadata_json)
3535
- VALUES (?, ?, ?, ?, ?)
3536
- ON CONFLICT (org_id, principal, slug) DO UPDATE SET
3537
- pinned_at = excluded.pinned_at,
3538
- metadata_json = excluded.metadata_json
3539
- RETURNING *`, [principal.orgId, principal.apiKeyId, slug, nowIso(), JSON.stringify(metadata)]);
3540
- return rowToPin(row);
3541
- }
3542
- async unpinSkill(principal, slug) {
3543
- const result = this.db.run("DELETE FROM skills_pins WHERE org_id = ? AND principal = ? AND slug = ?", [principal.orgId, principal.apiKeyId, slug]);
3544
- return result.changes > 0;
3545
- }
3546
- async listPins(principal) {
3547
- return this.all("SELECT * FROM skills_pins WHERE org_id = ? AND principal = ? ORDER BY slug ASC", [principal.orgId, principal.apiKeyId]).map(rowToPin);
3548
- }
3549
- async listTags(principal) {
3550
- await this.purgeExpiredTombstones(principal);
3551
- return this.all("SELECT DISTINCT t.tag FROM skills_tags t JOIN skills_registry s ON s.org_id=t.org_id AND s.slug=t.slug WHERE t.org_id = ? AND s.lifecycle='active' ORDER BY t.tag ASC", [principal.orgId]).map((row) => String(row.tag));
3552
- }
3553
- async listSkillsByTag(principal, tag) {
3554
- await this.purgeExpiredTombstones(principal);
3555
- return this.all(`SELECT s.* FROM skills_registry s
3556
- JOIN skills_tags t ON t.org_id = s.org_id AND t.slug = s.slug
3557
- WHERE t.org_id = ? AND t.tag = ? AND s.tombstoned_at IS NULL AND s.lifecycle = 'active'
3558
- ORDER BY s.slug ASC`, [principal.orgId, tag]).map(rowToSkill);
3559
- }
3560
- async listPinsByTag(principal, tag) {
3561
- await this.purgeExpiredTombstones(principal);
3562
- return this.all(`SELECT p.* FROM skills_pins p
3563
- JOIN skills_tags t ON t.org_id = p.org_id AND t.slug = p.slug
3564
- JOIN skills_registry s ON s.org_id = p.org_id AND s.slug = p.slug
3565
- WHERE p.org_id = ? AND p.principal = ? AND t.tag = ? AND s.tombstoned_at IS NULL AND s.lifecycle = 'active'
3566
- ORDER BY p.slug ASC`, [principal.orgId, principal.apiKeyId, tag]).map(rowToPin);
3567
- }
3568
- async listPublishedSlugs(principal) {
3569
- return this.all("SELECT slug FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NULL ORDER BY slug ASC", [principal.orgId]).map((row) => String(row.slug));
3570
- }
3571
- collectOrphanBundle(orgId, sha256) {
3572
- const referenced = this.get("SELECT 1 AS present FROM skills_registry WHERE org_id = ? AND bundle_sha256 = ? LIMIT 1", [orgId, sha256]) ?? this.get("SELECT 1 AS present FROM skills_versions WHERE org_id = ? AND bundle_sha256 = ? LIMIT 1", [orgId, sha256]);
3573
- if (referenced)
3574
- return;
3575
- this.db.run("DELETE FROM skills_bundles WHERE org_id = ? AND sha256 = ?", [orgId, sha256]);
3576
- }
3577
- get(sql, params) {
3578
- return this.db.query(sql).get(...params) ?? null;
3579
- }
3580
- all(sql, params) {
3581
- return this.db.query(sql).all(...params);
3582
- }
3583
- rollbackQuietly() {
3584
- try {
3585
- this.db.exec("ROLLBACK");
3586
- } catch {}
3587
- }
3588
- }
3589
- function applySqliteMigrations(db, migrationsDir = resolveMigrationsDir("sqlite")) {
3590
- db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
3591
- version text PRIMARY KEY,
3592
- applied_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
3593
- )`);
3594
- const files = readdirSync(migrationsDir).filter((name) => name.endsWith(".sql")).sort();
3595
- if (files.length === 0) {
3596
- throw new Error(`no .sql migrations found in ${migrationsDir}`);
3597
- }
3598
- const appliedNow = [];
3599
- for (const file of files) {
3600
- const version = file.replace(/\.sql$/, "");
3601
- const text = readFileSync2(join4(migrationsDir, file), "utf8");
3602
- const apply = db.transaction(() => {
3603
- const already = db.query("SELECT 1 AS present FROM schema_migrations WHERE version = ? LIMIT 1").get(version);
3604
- if (already)
3605
- return false;
3606
- db.exec(text);
3607
- db.run("INSERT INTO schema_migrations (version) VALUES (?)", [version]);
3608
- return true;
3609
- });
3610
- if (apply.immediate())
3611
- appliedNow.push(version);
3612
- }
3613
- return appliedNow;
3614
- }
3615
- function enableWalMode(db, attempts = 50, pauseMs = 10) {
3616
- for (let attempt = 0;attempt < attempts; attempt += 1) {
3617
- if (journalMode(db) === "wal")
3618
- return true;
3619
- try {
3620
- if (String(db.query("PRAGMA journal_mode = WAL").get()?.journal_mode).toLowerCase() === "wal") {
3621
- return true;
3622
- }
3623
- } catch {}
3624
- Bun.sleepSync(pauseMs);
3625
- }
3626
- return false;
3627
- }
3628
- function journalMode(db) {
3629
- try {
3630
- return String(db.query("PRAGMA journal_mode").get()?.journal_mode ?? "").toLowerCase();
3631
- } catch {
3632
- return "";
3633
- }
3634
- }
3635
- function openDatabase(path) {
3636
- try {
3637
- return new Database(path, { create: true, readwrite: true });
3638
- } catch (error) {
3639
- const reason = error instanceof Error ? error.message : String(error);
3640
- throw new Error(`cannot open the skills database at ${path}: ${reason}. ` + "Check that the directory exists and is writable. Set HASNA_SKILLS_DATABASE_URL to choose a " + "different path or a postgres:// URL, or HASNA_SKILLS_DIR to relocate the whole data directory.", { cause: error });
3641
- }
3642
- }
3643
- function parseScopes(value) {
3644
- if (Array.isArray(value))
3645
- return value.map(String);
3646
- if (typeof value !== "string")
3647
- return [];
3648
- try {
3649
- const parsed = JSON.parse(value);
3650
- return Array.isArray(parsed) ? parsed.map(String) : [];
3651
- } catch {
3652
- return [];
3653
- }
3654
- }
3655
-
3656
2657
  // src/server/store.ts
3657
2658
  function recordFieldsOf(input, carriedBundle, carriedSkillMd) {
3658
2659
  return {
@@ -3674,26 +2675,26 @@ function resolvePoolMax(env = process.env) {
3674
2675
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 4;
3675
2676
  }
3676
2677
  var LOG_SEQUENCE_ATTEMPTS = 12;
3677
- async function createStore(options = {}) {
3678
- const target = resolveDatabaseTarget(options.databaseUrl);
3679
- const store = instantiateStore(target, options.sqlite);
3680
- await store.verifyConnectivity?.();
3681
- if (options.bootstrapApiKey && store.ensureBootstrapApiKey) {
3682
- await store.ensureBootstrapApiKey(options.bootstrapApiKey);
3683
- }
3684
- return store;
3685
- }
3686
- function instantiateStore(target, sqliteOptions) {
3687
- switch (target.kind) {
3688
- case "postgres":
3689
- return new PostgresSkillsStore(target.url);
3690
- case "sqlite":
3691
- return new SqliteSkillsStore(target.path, sqliteOptions);
3692
- case "memory":
3693
- return new MemorySkillsStore;
2678
+ function openOperatorScopeMaintenance(databaseUrl) {
2679
+ try {
2680
+ const url = new URL(databaseUrl);
2681
+ if (!["postgres:", "postgresql:"].includes(url.protocol) || !url.hostname)
2682
+ throw new Error;
2683
+ } catch {
2684
+ throw new Error("maintenance requires an explicit Postgres database");
3694
2685
  }
2686
+ const store = new PostgresSkillsStore(databaseUrl, {
2687
+ max: 1,
2688
+ connectionTimeout: 5,
2689
+ idleTimeout: 5,
2690
+ connection: { statement_timeout: "5s", lock_timeout: "1s" }
2691
+ });
2692
+ return {
2693
+ inspectOperatorScopeTarget: (keyId, orgId) => store.inspectOperatorScopeTarget(keyId, orgId),
2694
+ enrollPublishScopeByOperator: (input) => store.enrollPublishScopeByOperator(input),
2695
+ close: () => store.close()
2696
+ };
3695
2697
  }
3696
-
3697
2698
  class MemorySkillsStore {
3698
2699
  selectionStore = new MemorySkillSelectionStore;
3699
2700
  executionGrantStore = new MemoryExecutionGrantStore;
@@ -3791,7 +2792,7 @@ class MemorySkillsStore {
3791
2792
  input: input.input,
3792
2793
  args: input.args,
3793
2794
  ...idemKey ? { idempotencyKey: idemKey } : {},
3794
- correlationId: randomUUID5(),
2795
+ correlationId: randomUUID4(),
3795
2796
  costCents: 0,
3796
2797
  leaseGeneration: 0,
3797
2798
  createdAt: now
@@ -4115,9 +3116,9 @@ class PostgresSkillsStore {
4115
3116
  }
4116
3117
  backend = { kind: "postgres", durable: true, label: "postgres" };
4117
3118
  sql;
4118
- constructor(databaseUrl) {
3119
+ constructor(databaseUrl, connectionOptions = {}) {
4119
3120
  const bunWithSql = Bun;
4120
- this.sql = new bunWithSql.SQL(databaseUrl, { max: resolvePoolMax() });
3121
+ this.sql = new bunWithSql.SQL(databaseUrl, { max: resolvePoolMax(), ...connectionOptions });
4121
3122
  }
4122
3123
  async verifyConnectivity() {
4123
3124
  try {
@@ -4212,7 +3213,7 @@ class PostgresSkillsStore {
4212
3213
  }
4213
3214
  await tx`
4214
3215
  INSERT INTO skills_audit_events (org_id, user_id, api_key_id, action, target_type, target_id, metadata_json)
4215
- VALUES (${actor.orgId}, ${actor.userId}, ${actor.apiKeyId}, ${"api_key_scopes_added"}, ${"api_key"}, ${keyId}, ${JSON.stringify({ added: addScopes, scopes: parseJsonArray(updated[0].scopes_json) })}::jsonb)
3216
+ VALUES (${actor.orgId}, ${actor.userId}, ${actor.apiKeyId}, ${"api_key_scopes_added"}, ${"api_key"}, ${keyId}, ${JSON.stringify({ added: addScopes, scopes: parseJsonArray(updated[0].scopes_json) })}::text::jsonb)
4216
3217
  `;
4217
3218
  return { kind: "updated", scopes: parseJsonArray(updated[0].scopes_json) };
4218
3219
  });
@@ -4220,10 +3221,12 @@ class PostgresSkillsStore {
4220
3221
  async casUpdateApiKeyScopes(tx, keyId, orgId, expectedScopes, addScopes) {
4221
3222
  const current = expectedScopes;
4222
3223
  const scopes = [...current, ...addScopes.filter((scope) => !current.includes(scope))];
3224
+ const expectedScopesJson = JSON.stringify(expectedScopes);
4223
3225
  return tx`
4224
- UPDATE api_keys SET scopes_json = ${JSON.stringify(scopes)}::jsonb
3226
+ UPDATE api_keys SET scopes_json = ${JSON.stringify(scopes)}::text::jsonb
4225
3227
  WHERE id = ${keyId} AND org_id = ${orgId} AND revoked_at IS NULL
4226
- AND scopes_json = ${JSON.stringify(expectedScopes)}::jsonb
3228
+ AND (scopes_json = ${expectedScopesJson}::text::jsonb
3229
+ OR scopes_json = to_jsonb(${expectedScopesJson}::text))
4227
3230
  RETURNING scopes_json
4228
3231
  `;
4229
3232
  }
@@ -4283,7 +3286,7 @@ class PostgresSkillsStore {
4283
3286
  operator_task_arn: input.operatorTaskArn,
4284
3287
  target_manifest_digest: input.manifestDigest,
4285
3288
  station_id: input.stationId
4286
- })}::jsonb)
3289
+ })}::text::jsonb)
4287
3290
  `;
4288
3291
  return { kind: "updated", scopes };
4289
3292
  });
@@ -4318,7 +3321,7 @@ class PostgresSkillsStore {
4318
3321
  const id = runId();
4319
3322
  const rows = await tx`
4320
3323
  INSERT INTO skills_runs (id, org_id, user_id, skill_slug, requested_slug, status, input_json, args_json, idempotency_key, correlation_id)
4321
- VALUES (${id}, ${input.principal.orgId}, ${input.principal.userId}, ${input.slug}, ${input.slug}, ${"queued"}, ${JSON.stringify(input.input)}::jsonb, ${JSON.stringify(input.args)}::jsonb, ${input.idempotencyKey ?? null}, ${randomUUID5()})
3324
+ VALUES (${id}, ${input.principal.orgId}, ${input.principal.userId}, ${input.slug}, ${input.slug}, ${"queued"}, ${JSON.stringify(input.input)}::jsonb, ${JSON.stringify(input.args)}::jsonb, ${input.idempotencyKey ?? null}, ${randomUUID4()})
4322
3325
  RETURNING *
4323
3326
  `;
4324
3327
  return rowToRun(rows[0]);
@@ -4567,10 +3570,10 @@ class PostgresSkillsStore {
4567
3570
  tombstone_purge_after = NULL,
4568
3571
  updated_at = EXCLUDED.updated_at
4569
3572
  WHERE (skills_registry.tombstoned_at IS NOT NULL
4570
- OR skills_registry.revision_id = ${input.expectedRevisionId ?? NO_REVISION_SENTINEL2})
3573
+ OR skills_registry.revision_id = ${input.expectedRevisionId ?? NO_REVISION_SENTINEL})
4571
3574
  AND (NOT ${Boolean(input.seedBundledOnly)} OR
4572
3575
  (skills_registry.tombstoned_at IS NULL AND skills_registry.source = 'bundled'
4573
- AND skills_registry.revision_id = ${input.expectedRevisionId ?? NO_REVISION_SENTINEL2}))
3576
+ AND skills_registry.revision_id = ${input.expectedRevisionId ?? NO_REVISION_SENTINEL}))
4574
3577
  RETURNING *
4575
3578
  `;
4576
3579
  if (!rows[0]) {
@@ -4848,7 +3851,7 @@ class PostgresSkillsStore {
4848
3851
  `;
4849
3852
  }
4850
3853
  }
4851
- var NO_REVISION_SENTINEL2 = "0000000000000000000000000000000000000000000000000000000000000000";
3854
+ var NO_REVISION_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000";
4852
3855
  function isUniqueViolation(error) {
4853
3856
  const code = error?.code;
4854
3857
  if (code === "23505" || code === 23505)
@@ -4862,7 +3865,116 @@ function connectionFailureSummary(error) {
4862
3865
  `)[0].slice(0, 200);
4863
3866
  }
4864
3867
 
3868
+ // src/server/enrollment-inspection.ts
3869
+ var {SQL } = globalThis.Bun;
3870
+ class EnrollmentInspectionError extends Error {
3871
+ code;
3872
+ constructor(code) {
3873
+ super(code);
3874
+ this.code = code;
3875
+ this.name = "EnrollmentInspectionError";
3876
+ }
3877
+ }
3878
+ var refuse = (code) => {
3879
+ throw new EnrollmentInspectionError(code);
3880
+ };
3881
+ var boundedId = (value) => typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\u0000-\u001f\u007f]/.test(value);
3882
+ async function inspectEnrollment(databaseUrl, input) {
3883
+ if (!input || !boundedId(input.keyId) || !boundedId(input.orgId) || !boundedId(input.enrollmentOperationId) || typeof input.enrollmentManifestDigest !== "string" || !/^[a-f0-9]{64}$/.test(input.enrollmentManifestDigest)) {
3884
+ refuse("INVALID_INSPECTION_INPUT");
3885
+ }
3886
+ try {
3887
+ const url = new URL(databaseUrl);
3888
+ if (!["postgres:", "postgresql:"].includes(url.protocol) || !url.hostname)
3889
+ refuse("POSTGRES_REQUIRED");
3890
+ } catch {
3891
+ refuse("POSTGRES_REQUIRED");
3892
+ }
3893
+ let sql;
3894
+ try {
3895
+ sql = new SQL(databaseUrl, { max: 1, connectionTimeout: 5, idleTimeout: 5 });
3896
+ return await sql.begin("ISOLATION LEVEL REPEATABLE READ READ ONLY", async (tx) => {
3897
+ await tx.unsafe("SET LOCAL statement_timeout = '5s'");
3898
+ await tx.unsafe("SET LOCAL lock_timeout = '1s'");
3899
+ const mode = await tx`SELECT current_setting('transaction_read_only') AS read_only,
3900
+ current_setting('transaction_isolation') AS isolation`;
3901
+ if (mode.length !== 1 || mode[0].read_only !== "on" || mode[0].isolation !== "repeatable read")
3902
+ refuse("INSPECTION_FAILED");
3903
+ const targets = await tx`SELECT org_id = ${input.orgId} AS org_matches,
3904
+ revoked_at IS NOT NULL AS revoked,
3905
+ CASE WHEN org_id = ${input.orgId} AND revoked_at IS NULL
3906
+ AND octet_length(scopes_json::text) <= 8192 THEN scopes_json::text ELSE NULL END AS scopes_json
3907
+ FROM api_keys WHERE id = ${input.keyId} LIMIT 2`;
3908
+ let target;
3909
+ if (targets.length > 1)
3910
+ target = { state: "ambiguous" };
3911
+ else if (!targets.length)
3912
+ target = { state: "absent" };
3913
+ else {
3914
+ const row = targets[0];
3915
+ if (typeof row.org_matches !== "boolean" || typeof row.revoked !== "boolean")
3916
+ refuse("INVALID_INSPECTION_RESULT");
3917
+ if (!row.org_matches)
3918
+ target = { state: "mismatched" };
3919
+ else if (row.revoked)
3920
+ target = { state: "revoked" };
3921
+ else {
3922
+ let scopes;
3923
+ try {
3924
+ scopes = typeof row.scopes_json === "string" ? JSON.parse(row.scopes_json) : null;
3925
+ } catch {
3926
+ refuse("INVALID_INSPECTION_RESULT");
3927
+ }
3928
+ if (!validOperatorScopeList(scopes))
3929
+ return refuse("INVALID_INSPECTION_RESULT");
3930
+ target = { state: "found", scopes: [...scopes] };
3931
+ }
3932
+ }
3933
+ const audits = await tx`SELECT org_id = ${input.orgId} AS org_matches,
3934
+ target_type = 'api_key' AND target_id = ${input.keyId} AS target_matches,
3935
+ jsonb_typeof(metadata_json) = 'object'
3936
+ AND jsonb_typeof(metadata_json->'target_manifest_digest') = 'string'
3937
+ AND (metadata_json->>'target_manifest_digest') ~ '^[a-f0-9]{64}$' AS digest_valid,
3938
+ metadata_json->>'target_manifest_digest' = ${input.enrollmentManifestDigest} AS digest_matches
3939
+ FROM skills_audit_events WHERE action = 'api_key_scopes_added'
3940
+ AND operator_operation_id = ${input.enrollmentOperationId} LIMIT 2`;
3941
+ let audit;
3942
+ if (audits.length > 1)
3943
+ audit = { state: "ambiguous" };
3944
+ else if (!audits.length)
3945
+ audit = { state: "absent" };
3946
+ else {
3947
+ const row = audits[0];
3948
+ if (row.digest_valid !== true || [row.org_matches, row.target_matches, row.digest_matches].some((value) => typeof value !== "boolean")) {
3949
+ refuse("INVALID_INSPECTION_RESULT");
3950
+ }
3951
+ audit = { state: row.org_matches && row.target_matches && row.digest_matches ? "matching" : "mismatched" };
3952
+ }
3953
+ return { status: "inspected", target, audit };
3954
+ });
3955
+ } catch (error) {
3956
+ if (error instanceof EnrollmentInspectionError)
3957
+ throw error;
3958
+ const state2 = error instanceof SQL.PostgresError ? error.errno : undefined;
3959
+ const code = state2 === "42P01" || state2 === "42703" ? "INSPECTION_SCHEMA_UNAVAILABLE" : state2 === "42501" ? "INSPECTION_ACCESS_DENIED" : state2 === "25006" ? "INSPECTION_WRITE_REFUSED" : state2 === "57014" || state2 === "55P03" ? "INSPECTION_TIMEOUT" : ["08000", "08001", "08003", "08004", "08006", "08007", "08P01", "28000", "28P01", "57P03"].includes(state2 ?? "") ? "INSPECTION_CONNECTION_FAILED" : "INSPECTION_FAILED";
3960
+ throw new EnrollmentInspectionError(code);
3961
+ } finally {
3962
+ try {
3963
+ await sql?.close({ timeout: 1 });
3964
+ } catch {
3965
+ throw new EnrollmentInspectionError("INSPECTION_FAILED");
3966
+ }
3967
+ }
3968
+ }
3969
+
4865
3970
  // src/server/maintenance-entry.ts
3971
+ class InspectionInputError extends Error {
3972
+ code;
3973
+ constructor(code) {
3974
+ super(code);
3975
+ this.code = code;
3976
+ }
3977
+ }
4866
3978
  var MAX_MANIFEST_BYTES = 32 * 1024;
4867
3979
  function sha256(value) {
4868
3980
  return createHash3("sha256").update(value).digest("hex");
@@ -4896,6 +4008,17 @@ function readBoundedJson(path) {
4896
4008
  closeSync(fd);
4897
4009
  }
4898
4010
  }
4011
+ function verifyOperatorReceipt(manifestInput, receipt) {
4012
+ if (typeof receipt.operatorJobId !== "string" || !receipt.operatorJobId.trim() || typeof receipt.operatorTaskArn !== "string" || !receipt.operatorTaskArn.trim())
4013
+ throw new Error("verified operator receipt is incomplete");
4014
+ if (typeof receipt.manifestBytesSha256 !== "string" || !/^[a-f0-9]{64}$/.test(receipt.manifestBytesSha256))
4015
+ throw new Error("verified operator receipt digest is invalid");
4016
+ if (manifestInput.bytesSha256 !== receipt.manifestBytesSha256)
4017
+ throw new Error("manifest bytes are not the bytes verified by the operator wrapper");
4018
+ if (typeof receipt.taskDefinitionArn !== "string" || !receipt.taskDefinitionArn.trim() || typeof receipt.verifiedAt !== "string" || !Number.isFinite(Date.parse(receipt.verifiedAt)))
4019
+ throw new Error("verified operator receipt provenance is invalid");
4020
+ return { operatorJobId: receipt.operatorJobId, operatorTaskArn: receipt.operatorTaskArn };
4021
+ }
4899
4022
  function readManifest(path, receiptPath) {
4900
4023
  const manifestInput = readBoundedJson(path);
4901
4024
  const receiptInput = readBoundedJson(receiptPath);
@@ -4921,18 +4044,71 @@ function readManifest(path, receiptPath) {
4921
4044
  throw new Error("maintenance manifest digest must be SHA-256");
4922
4045
  if (sha256(canonical(parsed)) !== parsed.manifestDigest)
4923
4046
  throw new Error("maintenance manifest digest does not match its bytes");
4924
- if (typeof receipt.operatorJobId !== "string" || !receipt.operatorJobId.trim() || typeof receipt.operatorTaskArn !== "string" || !receipt.operatorTaskArn.trim())
4925
- throw new Error("verified operator receipt is incomplete");
4926
- if (typeof receipt.manifestBytesSha256 !== "string" || !/^[a-f0-9]{64}$/.test(receipt.manifestBytesSha256))
4927
- throw new Error("verified operator receipt digest is invalid");
4928
- if (manifestInput.bytesSha256 !== receipt.manifestBytesSha256)
4929
- throw new Error("manifest bytes are not the bytes verified by the operator wrapper");
4930
- if (typeof receipt.taskDefinitionArn !== "string" || !receipt.taskDefinitionArn.trim() || typeof receipt.verifiedAt !== "string" || !Number.isFinite(Date.parse(receipt.verifiedAt)))
4931
- throw new Error("verified operator receipt provenance is invalid");
4932
- return { ...parsed, operatorJobId: receipt.operatorJobId, operatorTaskArn: receipt.operatorTaskArn };
4047
+ return { ...parsed, ...verifyOperatorReceipt(manifestInput, receipt) };
4048
+ }
4049
+ function readInspectionManifest(path, receiptPath) {
4050
+ let input;
4051
+ let parsed;
4052
+ try {
4053
+ input = readBoundedJson(path);
4054
+ const value = input.value;
4055
+ if (!value || typeof value !== "object" || Array.isArray(value))
4056
+ throw new Error;
4057
+ const fields = ["app", "operation", "manifestVersion", "operationId", "stationId", "keyId", "orgId", "enrollmentOperationId", "enrollmentManifestDigest", "expiresAt", "manifestDigest"];
4058
+ if (Object.keys(value).length !== fields.length || fields.some((field) => !Object.hasOwn(value, field)))
4059
+ throw new Error;
4060
+ if (value.app !== "skills" || value.operation !== "inspect-enrollment" || value.manifestVersion !== 1)
4061
+ throw new Error;
4062
+ for (const field of ["operationId", "stationId", "keyId", "orgId", "enrollmentOperationId"]) {
4063
+ const item = value[field];
4064
+ if (typeof item !== "string" || item.trim().length === 0 || item.length > 256 || /[\u0000-\u001f\u007f]/.test(item))
4065
+ throw new Error;
4066
+ }
4067
+ if (value.operationId === value.enrollmentOperationId)
4068
+ throw new Error;
4069
+ for (const field of ["manifestDigest", "enrollmentManifestDigest"]) {
4070
+ if (typeof value[field] !== "string" || !/^[a-f0-9]{64}$/.test(value[field]))
4071
+ throw new Error;
4072
+ }
4073
+ if (typeof value.expiresAt !== "string" || value.expiresAt.length > 64 || !Number.isFinite(Date.parse(value.expiresAt)) || Date.parse(value.expiresAt) <= Date.now())
4074
+ throw new Error;
4075
+ if (sha256(canonical(value)) !== value.manifestDigest)
4076
+ throw new Error;
4077
+ parsed = value;
4078
+ } catch {
4079
+ throw new InspectionInputError("INVALID_INSPECTION_MANIFEST");
4080
+ }
4081
+ try {
4082
+ verifyOperatorReceipt(input, readBoundedJson(receiptPath).value);
4083
+ } catch {
4084
+ throw new InspectionInputError("INVALID_OPERATOR_RECEIPT");
4085
+ }
4086
+ return parsed;
4933
4087
  }
4934
4088
  function registerMaintenance(parent) {
4935
4089
  const maintenance = parent.command("maintenance").description("Run protected, metadata-only Skills maintenance operations");
4090
+ maintenance.command("inspect-enrollment").description("Inspect one enrollment with a database-enforced read-only snapshot").requiredOption("--manifest <path>", "Fresh inspection authorization supplied by the protected task wrapper").requiredOption("--operator-receipt <path>", "Verified actor receipt written by the protected task wrapper").option("--json", "Output a safe JSON receipt", false).action(async (options) => {
4091
+ try {
4092
+ const manifest = readInspectionManifest(options.manifest, options.operatorReceipt);
4093
+ const result = await inspectEnrollment(process.env.HASNA_SKILLS_DATABASE_URL ?? "", manifest);
4094
+ console.log(JSON.stringify({
4095
+ ...result,
4096
+ operationId: manifest.operationId,
4097
+ enrollmentOperationId: manifest.enrollmentOperationId,
4098
+ enrollmentManifestDigest: manifest.enrollmentManifestDigest,
4099
+ keyId: manifest.keyId,
4100
+ orgId: manifest.orgId,
4101
+ stationId: manifest.stationId
4102
+ }));
4103
+ } catch (error) {
4104
+ const code = error instanceof InspectionInputError || error instanceof EnrollmentInspectionError ? error.code : "INSPECTION_FAILED";
4105
+ if (options.json || !process.stdout.isTTY)
4106
+ console.log(JSON.stringify({ status: "failed", code }));
4107
+ else
4108
+ console.error(code);
4109
+ process.exitCode = 1;
4110
+ }
4111
+ });
4936
4112
  maintenance.command("enroll-publish").requiredOption("--manifest <path>", "Bounded target manifest supplied by the protected task wrapper").requiredOption("--operator-receipt <path>", "Verified actor receipt written by the protected task wrapper").option("--apply", "Commit the bounded scope update; defaults to dry-run", false).option("--json", "Output a safe JSON receipt", false).action(async (options) => {
4937
4113
  let manifest;
4938
4114
  let store;
@@ -4943,8 +4119,8 @@ function registerMaintenance(parent) {
4943
4119
  const databaseUrl = process.env.HASNA_SKILLS_DATABASE_URL;
4944
4120
  if (!databaseUrl)
4945
4121
  throw new Error("maintenance requires HASNA_SKILLS_DATABASE_URL");
4946
- store = await createStore({ databaseUrl });
4947
- const snapshot = await store.inspectOperatorScopeTarget?.(manifest.keyId, manifest.orgId);
4122
+ store = openOperatorScopeMaintenance(databaseUrl);
4123
+ const snapshot = await store.inspectOperatorScopeTarget(manifest.keyId, manifest.orgId);
4948
4124
  if (!snapshot)
4949
4125
  throw new Error("configured store does not support operator maintenance");
4950
4126
  if (snapshot.kind !== "found")
@@ -4956,7 +4132,7 @@ function registerMaintenance(parent) {
4956
4132
  console.log(JSON.stringify(base));
4957
4133
  return;
4958
4134
  }
4959
- const result = await store.enrollPublishScopeByOperator?.(manifest);
4135
+ const result = await store.enrollPublishScopeByOperator(manifest);
4960
4136
  if (!result)
4961
4137
  throw new Error("configured store does not support operator maintenance");
4962
4138
  if ("scopes" in result && !validOperatorScopeList(result.scopes))
@@ -4967,14 +4143,20 @@ function registerMaintenance(parent) {
4967
4143
  process.exitCode = 1;
4968
4144
  } catch (error) {
4969
4145
  const message = error instanceof Error ? error.message : "";
4970
- const code = message.includes("expired") || message.includes("expiry") ? "INVALID_EXPIRY" : message.includes("receipt") || message.includes("operator") ? "INVALID_OPERATOR_RECEIPT" : message.includes("manifest") || message.includes("input") ? "INVALID_MANIFEST" : message.includes("database") || message.includes("store") ? "STORE_UNAVAILABLE" : "MAINTENANCE_FAILED";
4146
+ const state2 = error instanceof SQL2.PostgresError ? error.errno : undefined;
4147
+ const code = state2 === "42P01" || state2 === "42703" ? "MAINTENANCE_SCHEMA_UNAVAILABLE" : state2 === "42501" ? "MAINTENANCE_ACCESS_DENIED" : state2 === "25006" ? "MAINTENANCE_WRITE_REFUSED" : state2 === "57014" || state2 === "55P03" ? "MAINTENANCE_TIMEOUT" : ["08000", "08001", "08003", "08004", "08006", "08007", "08P01", "28000", "28P01", "57P03"].includes(state2 ?? "") ? "MAINTENANCE_CONNECTION_FAILED" : message.includes("expired") || message.includes("expiry") ? "INVALID_EXPIRY" : message.includes("receipt") || message.includes("operator") ? "INVALID_OPERATOR_RECEIPT" : message.includes("manifest") || message.includes("input") ? "INVALID_MANIFEST" : message.includes("database") || message.includes("store") ? "STORE_UNAVAILABLE" : "MAINTENANCE_FAILED";
4971
4148
  if (options.json || !process.stdout.isTTY)
4972
4149
  console.log(JSON.stringify({ status: "failed", code }));
4973
4150
  else
4974
4151
  console.error(code);
4975
4152
  process.exitCode = 1;
4976
4153
  } finally {
4977
- await store?.close?.();
4154
+ try {
4155
+ await store?.close();
4156
+ } catch {
4157
+ console.log(JSON.stringify({ status: "failed", code: "MAINTENANCE_CLOSE_FAILED" }));
4158
+ process.exitCode = 1;
4159
+ }
4978
4160
  }
4979
4161
  });
4980
4162
  }