@hasna/skills 0.8.12 → 0.9.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.
package/bin/server.js CHANGED
@@ -24122,7 +24122,7 @@ var init_dist_es9 = __esm(() => {
24122
24122
  // package.json
24123
24123
  var package_default = {
24124
24124
  name: "@hasna/skills",
24125
- version: "0.8.12",
24125
+ version: "0.9.2",
24126
24126
  description: "Skills library for AI coding agents",
24127
24127
  type: "module",
24128
24128
  bin: {
@@ -34897,6 +34897,9 @@ class MemorySkillSelectionStore {
34897
34897
  async getProfile(p2, id) {
34898
34898
  return structuredClone(this.profiles.get(this.key(p2.orgId, id)) ?? null);
34899
34899
  }
34900
+ async profilesReferencingSkill(p2, slug) {
34901
+ return [...this.profiles.values()].filter((profile2) => profile2.workspaceId === p2.orgId && profile2.selections.some((selection) => selection.slug === slug)).map((profile2) => profile2.id).sort();
34902
+ }
34900
34903
  async saveProfile(p2, id, selected, expected) {
34901
34904
  const key = this.key(p2.orgId, id), previous = this.profiles.get(key);
34902
34905
  if ((previous?.revision ?? null) !== expected)
@@ -34934,14 +34937,25 @@ class SqliteSkillSelectionStore {
34934
34937
  constructor(db) {
34935
34938
  this.db = db;
34936
34939
  }
34940
+ async profilesReferencingSkill(p2, slug) {
34941
+ const rows = this.db.query("SELECT profile_id,selections_json FROM skills_profiles WHERE org_id=?").all(p2.orgId);
34942
+ return rows.filter((row) => selections(row.selections_json).some((selection) => selection.slug === slug)).map((row) => String(row.profile_id)).sort();
34943
+ }
34937
34944
  async getProfile(p2, id) {
34938
34945
  const row = this.db.query("SELECT * FROM skills_profiles WHERE org_id=? AND profile_id=?").get(p2.orgId, id);
34939
34946
  return row ? profile(row) : null;
34940
34947
  }
34941
34948
  async saveProfile(p2, id, selected, expected) {
34942
- const revision = randomUUID2(), timestamp = new Date().toISOString();
34943
- 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(p2.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, p2.orgId, id, expected);
34944
- return row ? profile(row) : null;
34949
+ return this.db.transaction(() => {
34950
+ for (const selection of [...selected].sort((a3, b3) => a3.slug.localeCompare(b3.slug))) {
34951
+ const skill = this.db.query("SELECT lifecycle FROM skills_registry WHERE org_id=? AND slug=?").get(p2.orgId, selection.slug);
34952
+ if (skill?.lifecycle === "archived")
34953
+ return null;
34954
+ }
34955
+ const revision = randomUUID2(), timestamp = new Date().toISOString();
34956
+ 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(p2.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, p2.orgId, id, expected);
34957
+ return row ? profile(row) : null;
34958
+ }).immediate();
34945
34959
  }
34946
34960
  async getStationState(p2, id) {
34947
34961
  const row = this.db.query("SELECT * FROM skills_station_state WHERE org_id=? AND actor_id=? AND station_id=?").get(p2.orgId, p2.userId, id);
@@ -34960,14 +34974,25 @@ class PostgresSkillSelectionStore {
34960
34974
  constructor(sql) {
34961
34975
  this.sql = sql;
34962
34976
  }
34977
+ async profilesReferencingSkill(p2, slug) {
34978
+ const rows = await this.sql`SELECT profile_id,selections_json FROM skills_profiles WHERE org_id=${p2.orgId}`;
34979
+ return rows.filter((row) => selections(row.selections_json).some((selection) => selection.slug === slug)).map((row) => String(row.profile_id)).sort();
34980
+ }
34963
34981
  async getProfile(p2, id) {
34964
34982
  const rows = await this.sql`SELECT * FROM skills_profiles WHERE org_id=${p2.orgId} AND profile_id=${id}`;
34965
34983
  return rows[0] ? profile(rows[0]) : null;
34966
34984
  }
34967
34985
  async saveProfile(p2, id, selected, expected) {
34968
- const revision = randomUUID2(), timestamp = new Date().toISOString();
34969
- const rows = expected === null ? await this.sql`INSERT INTO skills_profiles(org_id,profile_id,revision,selections_json,updated_at) VALUES(${p2.orgId},${id},${revision},${JSON.stringify(selected)}::jsonb,${timestamp}) ON CONFLICT(org_id,profile_id) DO NOTHING RETURNING *` : await this.sql`UPDATE skills_profiles SET revision=${revision},selections_json=${JSON.stringify(selected)}::jsonb,updated_at=${timestamp} WHERE org_id=${p2.orgId} AND profile_id=${id} AND revision=${expected} RETURNING *`;
34970
- return rows[0] ? profile(rows[0]) : null;
34986
+ return this.sql.begin(async (tx) => {
34987
+ for (const selection of [...selected].sort((a3, b3) => a3.slug.localeCompare(b3.slug))) {
34988
+ const rows2 = await tx`SELECT lifecycle FROM skills_registry WHERE org_id=${p2.orgId} AND slug=${selection.slug} FOR UPDATE`;
34989
+ if (rows2[0]?.lifecycle === "archived")
34990
+ return null;
34991
+ }
34992
+ const revision = randomUUID2(), timestamp = new Date().toISOString();
34993
+ const rows = expected === null ? await tx`INSERT INTO skills_profiles(org_id,profile_id,revision,selections_json,updated_at) VALUES(${p2.orgId},${id},${revision},${JSON.stringify(selected)}::jsonb,${timestamp}) ON CONFLICT(org_id,profile_id) DO NOTHING RETURNING *` : await tx`UPDATE skills_profiles SET revision=${revision},selections_json=${JSON.stringify(selected)}::jsonb,updated_at=${timestamp} WHERE org_id=${p2.orgId} AND profile_id=${id} AND revision=${expected} RETURNING *`;
34994
+ return rows[0] ? profile(rows[0]) : null;
34995
+ });
34971
34996
  }
34972
34997
  async getStationState(p2, id) {
34973
34998
  const rows = await this.sql`SELECT * FROM skills_station_state WHERE org_id=${p2.orgId} AND actor_id=${p2.userId} AND station_id=${id}`;
@@ -35161,7 +35186,11 @@ function rowToSkill(row) {
35161
35186
  revisionId: String(row.revision_id ?? ""),
35162
35187
  revisionNumber: Number(row.revision_number ?? 0),
35163
35188
  ...row.tombstoned_at ? { tombstonedAt: dateString(row.tombstoned_at) } : {},
35164
- ...row.tombstone_purge_after ? { tombstonePurgeAfter: dateString(row.tombstone_purge_after) } : {}
35189
+ ...row.tombstone_purge_after ? { tombstonePurgeAfter: dateString(row.tombstone_purge_after) } : {},
35190
+ lifecycle: row.lifecycle === "archived" ? "archived" : "active",
35191
+ ...row.archived_at ? { archivedAt: dateString(row.archived_at) } : {},
35192
+ ...row.archive_reason ? { archiveReason: String(row.archive_reason) } : {},
35193
+ ...row.replacement_slug ? { replacementSlug: String(row.replacement_slug) } : {}
35165
35194
  };
35166
35195
  }
35167
35196
  function rowToPin(row) {
@@ -35280,11 +35309,22 @@ class SkillVersionExistsError extends Error {
35280
35309
  }
35281
35310
  }
35282
35311
 
35312
+ class SkillLifecycleConflictError extends Error {
35313
+ slug;
35314
+ profiles;
35315
+ constructor(slug, profiles) {
35316
+ super(`cannot archive '${slug}' while active profiles reference it: ${profiles.join(", ")}`);
35317
+ this.slug = slug;
35318
+ this.profiles = profiles;
35319
+ this.name = "SkillLifecycleConflictError";
35320
+ }
35321
+ }
35322
+
35283
35323
  // src/lib/revision.ts
35284
35324
  import { createHash as createHash5 } from "crypto";
35285
35325
  var REVISION_ID_PATTERN = /^[0-9a-f]{64}$/;
35286
35326
  function revisionIdOf(content) {
35287
- const canonical = JSON.stringify({
35327
+ const canonicalContent = {
35288
35328
  slug: content.slug,
35289
35329
  displayName: content.displayName,
35290
35330
  description: content.description,
@@ -35296,7 +35336,13 @@ function revisionIdOf(content) {
35296
35336
  skillMd: content.skillMd ?? null,
35297
35337
  bundleSha256: content.bundleSha256 ?? null,
35298
35338
  bundleByteSize: content.bundleByteSize ?? null
35299
- });
35339
+ };
35340
+ if (content.lifecycle === "archived" || content.archiveReason !== undefined || content.replacementSlug !== undefined) {
35341
+ canonicalContent.lifecycle = content.lifecycle ?? "active";
35342
+ canonicalContent.archiveReason = content.archiveReason ?? null;
35343
+ canonicalContent.replacementSlug = content.replacementSlug ?? null;
35344
+ }
35345
+ const canonical = JSON.stringify(canonicalContent);
35300
35346
  return createHash5("sha256").update(canonical).digest("hex");
35301
35347
  }
35302
35348
  function revisionIdOfRecord(record) {
@@ -35399,6 +35445,28 @@ class SqliteSkillsStore {
35399
35445
  scopes: parseScopes(row.scopes_json)
35400
35446
  };
35401
35447
  }
35448
+ async updateApiKeyScopes(actor, keyId, expectedScopes, addScopes) {
35449
+ 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]);
35450
+ if (!row)
35451
+ return { kind: "not_found" };
35452
+ const current = parseScopes(row.scopes_json);
35453
+ if (current.length !== expectedScopes.length || current.some((scope, index) => scope !== expectedScopes[index])) {
35454
+ return { kind: "stale", scopes: current };
35455
+ }
35456
+ const scopes = [...current, ...addScopes.filter((scope) => !current.includes(scope))];
35457
+ const tx = this.db.transaction(() => {
35458
+ 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)]);
35459
+ if (changed.changes !== 1)
35460
+ return false;
35461
+ 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 })]);
35462
+ return true;
35463
+ });
35464
+ if (!tx()) {
35465
+ 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]);
35466
+ return { kind: "stale", scopes: latest ? parseScopes(latest.scopes_json) : current };
35467
+ }
35468
+ return { kind: "updated", scopes };
35469
+ }
35402
35470
  async createRun(input) {
35403
35471
  if (input.idempotencyKey) {
35404
35472
  const existing = this.get("SELECT * FROM skills_runs WHERE org_id = ? AND idempotency_key = ? LIMIT 1", [input.principal.orgId, input.idempotencyKey]);
@@ -35720,6 +35788,39 @@ class SqliteSkillsStore {
35720
35788
  const row = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
35721
35789
  return row ? rowToSkill(row) : null;
35722
35790
  }
35791
+ async setSkillLifecycle(principal, slug, patch, expectedRevisionId) {
35792
+ return this.db.transaction(() => {
35793
+ const rowCurrent = this.get("SELECT * FROM skills_registry WHERE org_id=? AND slug=? LIMIT 1", [principal.orgId, slug]);
35794
+ const current = rowCurrent ? rowToSkill(rowCurrent) : null;
35795
+ if (!current || current.tombstonedAt)
35796
+ return null;
35797
+ if (expectedRevisionId !== current.revisionId)
35798
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
35799
+ if (patch.lifecycle === "archived" && current.lifecycle !== "archived") {
35800
+ const profiles = this.db.query("SELECT profile_id,selections_json FROM skills_profiles WHERE org_id=?").all(principal.orgId);
35801
+ const referencing = profiles.filter((profile2) => {
35802
+ const selections2 = typeof profile2.selections_json === "string" ? JSON.parse(profile2.selections_json) : profile2.selections_json;
35803
+ return Array.isArray(selections2) && selections2.some((selection) => selection?.slug === slug);
35804
+ }).map((profile2) => String(profile2.profile_id)).sort();
35805
+ if (referencing.length)
35806
+ throw new SkillLifecycleConflictError(slug, referencing);
35807
+ }
35808
+ 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 } : {} } : {} };
35809
+ if (patch.lifecycle === "active") {
35810
+ delete next2.archivedAt;
35811
+ delete next2.archiveReason;
35812
+ delete next2.replacementSlug;
35813
+ }
35814
+ 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]);
35815
+ if (!row)
35816
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, this.getSkillSync(principal, slug)?.revisionId ?? null);
35817
+ return rowToSkill(row);
35818
+ }).immediate();
35819
+ }
35820
+ getSkillSync(principal, slug) {
35821
+ const row = this.get("SELECT * FROM skills_registry WHERE org_id=? AND slug=? LIMIT 1", [principal.orgId, slug]);
35822
+ return row ? rowToSkill(row) : null;
35823
+ }
35723
35824
  async updateSkill(principal, slug, patch, expectedRevisionId) {
35724
35825
  const current = await this.getSkill(principal, slug);
35725
35826
  if (!current || current.tombstonedAt)
@@ -35832,13 +35933,13 @@ class SqliteSkillsStore {
35832
35933
  }
35833
35934
  async listTags(principal) {
35834
35935
  await this.purgeExpiredTombstones(principal);
35835
- return this.all("SELECT DISTINCT tag FROM skills_tags WHERE org_id = ? ORDER BY tag ASC", [principal.orgId]).map((row) => String(row.tag));
35936
+ 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));
35836
35937
  }
35837
35938
  async listSkillsByTag(principal, tag) {
35838
35939
  await this.purgeExpiredTombstones(principal);
35839
35940
  return this.all(`SELECT s.* FROM skills_registry s
35840
35941
  JOIN skills_tags t ON t.org_id = s.org_id AND t.slug = s.slug
35841
- WHERE t.org_id = ? AND t.tag = ? AND s.tombstoned_at IS NULL
35942
+ WHERE t.org_id = ? AND t.tag = ? AND s.tombstoned_at IS NULL AND s.lifecycle = 'active'
35842
35943
  ORDER BY s.slug ASC`, [principal.orgId, tag]).map(rowToSkill);
35843
35944
  }
35844
35945
  async listPinsByTag(principal, tag) {
@@ -35846,7 +35947,7 @@ class SqliteSkillsStore {
35846
35947
  return this.all(`SELECT p.* FROM skills_pins p
35847
35948
  JOIN skills_tags t ON t.org_id = p.org_id AND t.slug = p.slug
35848
35949
  JOIN skills_registry s ON s.org_id = p.org_id AND s.slug = p.slug
35849
- WHERE p.org_id = ? AND p.principal = ? AND t.tag = ? AND s.tombstoned_at IS NULL
35950
+ WHERE p.org_id = ? AND p.principal = ? AND t.tag = ? AND s.tombstoned_at IS NULL AND s.lifecycle = 'active'
35850
35951
  ORDER BY p.slug ASC`, [principal.orgId, principal.apiKeyId, tag]).map(rowToPin);
35851
35952
  }
35852
35953
  async listPublishedSlugs(principal) {
@@ -36795,6 +36896,15 @@ class SkillRequestError extends Error {
36795
36896
  this.code = code;
36796
36897
  }
36797
36898
  }
36899
+ function skillLifecyclePatch(body) {
36900
+ if (body.lifecycle !== "active" && body.lifecycle !== "archived")
36901
+ throw new SkillRequestError(400, "INVALID_LIFECYCLE", "lifecycle must be active or archived");
36902
+ if (body.reason !== undefined && (typeof body.reason !== "string" || body.reason.length > 512))
36903
+ throw new SkillRequestError(400, "INVALID_LIFECYCLE", "reason must be at most 512 characters");
36904
+ if (body.replacementSlug !== undefined && (typeof body.replacementSlug !== "string" || !SLUG_PATTERN.test(body.replacementSlug)))
36905
+ throw new SkillRequestError(400, "INVALID_LIFECYCLE", "replacementSlug must be a valid skill slug");
36906
+ return { lifecycle: body.lifecycle, ...body.reason ? { reason: body.reason } : {}, ...body.replacementSlug ? { replacementSlug: body.replacementSlug } : {} };
36907
+ }
36798
36908
  function publishedSkillMeta(record) {
36799
36909
  return {
36800
36910
  name: record.slug,
@@ -36819,7 +36929,11 @@ function publishedPayload(record, opts = {}) {
36819
36929
  publishedAt: record.createdAt,
36820
36930
  updatedAt: record.updatedAt,
36821
36931
  revisionId: record.revisionId,
36822
- revisionNumber: record.revisionNumber
36932
+ revisionNumber: record.revisionNumber,
36933
+ lifecycle: record.lifecycle,
36934
+ ...record.archivedAt ? { archivedAt: record.archivedAt } : {},
36935
+ ...record.archiveReason ? { archiveReason: record.archiveReason } : {},
36936
+ ...record.replacementSlug ? { replacementSlug: record.replacementSlug } : {}
36823
36937
  };
36824
36938
  }
36825
36939
  function revisionEtag(revisionId) {
@@ -36859,7 +36973,7 @@ async function tombstoneStatus(store, artifactStorage, principal, record) {
36859
36973
  };
36860
36974
  }
36861
36975
  async function listMergedSkills(store, principal) {
36862
- return (await store.listSkills(principal)).sort((a3, b3) => a3.slug.localeCompare(b3.slug)).map((record) => publishedPayload(record));
36976
+ return (await store.listSkills(principal)).filter((record) => record.lifecycle === "active").sort((a3, b3) => a3.slug.localeCompare(b3.slug)).map((record) => publishedPayload(record));
36863
36977
  }
36864
36978
  async function resolvePublishedSkill(store, artifactStorage, principal, slug) {
36865
36979
  const record = await store.getSkill(principal, slug);
@@ -36876,7 +36990,7 @@ async function listOrgTags(store, principal) {
36876
36990
  return [...new Set((await store.listTags(principal)).filter((tag) => tag.trim()))].sort();
36877
36991
  }
36878
36992
  async function listMergedSkillsByTag(store, principal, tag) {
36879
- return (await store.listSkillsByTag(principal, tag)).sort((a3, b3) => a3.slug.localeCompare(b3.slug)).map((record) => publishedPayload(record));
36993
+ return (await store.listSkillsByTag(principal, tag)).filter((record) => record.lifecycle === "active").sort((a3, b3) => a3.slug.localeCompare(b3.slug)).map((record) => publishedPayload(record));
36880
36994
  }
36881
36995
  function skillSummary(skill) {
36882
36996
  return {
@@ -37438,6 +37552,8 @@ async function validatePublished(store, principal, selections2) {
37438
37552
  const current = await store.getSkill(principal, selection2.slug);
37439
37553
  if (!current || current.tombstonedAt)
37440
37554
  throw new SkillRequestError(current?.tombstonedAt ? 410 : 404, "SELECTION_UNAVAILABLE", `Selected skill '${selection2.slug}' is unavailable`);
37555
+ if (current.lifecycle === "archived")
37556
+ throw new SkillRequestError(409, "SELECTION_ARCHIVED", `Selected skill '${selection2.slug}' is archived`);
37441
37557
  const version2 = await store.getSkillVersion(principal, selection2.slug, selection2.version);
37442
37558
  if (!version2 || `sha256:${version2.bundleSha256}` !== selection2.bundleDigest)
37443
37559
  throw new SkillRequestError(409, "SELECTION_VERSION_MISMATCH", `Selected version of '${selection2.slug}' does not match its published digest`);
@@ -38165,6 +38281,7 @@ function createSubmitRunService(options) {
38165
38281
  const policy = { ...DEFAULT_RUN_POLICY, ...input.policy };
38166
38282
  input = {
38167
38283
  ...input,
38284
+ ...input.executionContract ? { executionContract: { ...input.executionContract } } : {},
38168
38285
  systemDeps: [...input.systemDeps ?? []],
38169
38286
  policy: { ...policy, egressAllowlist: [...policy.egressAllowlist] },
38170
38287
  limits: { ...DEFAULT_RUN_LIMITS, ...input.limits }
@@ -38173,7 +38290,7 @@ function createSubmitRunService(options) {
38173
38290
  const byKey = await store.getRunByKey(input.tenantId, input.idempotencyKey);
38174
38291
  if (byKey)
38175
38292
  return { run: ownedAdmission(byKey.admission), created: false };
38176
- const byDigests = await store.getRunByDigests({
38293
+ const byDigests = input.executionContract ? null : await store.getRunByDigests({
38177
38294
  tenantId: input.tenantId,
38178
38295
  skillId: input.skillId,
38179
38296
  skillVersion: input.skillVersion,
@@ -38195,6 +38312,7 @@ function createSubmitRunService(options) {
38195
38312
  bundleDigest: input.bundleDigest,
38196
38313
  runtimeImageDigest: image.runtimeImageDigest,
38197
38314
  dependencyLayerTag: image.dependencyLayerTag,
38315
+ ...input.executionContract ? { executionContract: { ...input.executionContract } } : {},
38198
38316
  inputDigest,
38199
38317
  runtime: image.runtime,
38200
38318
  policy: { ...DEFAULT_RUN_POLICY, ...input.policy },
@@ -38210,6 +38328,7 @@ function createSubmitRunService(options) {
38210
38328
  function ownedAdmission(admission) {
38211
38329
  return {
38212
38330
  ...admission,
38331
+ ...admission.executionContract ? { executionContract: { ...admission.executionContract } } : {},
38213
38332
  policy: { ...admission.policy, egressAllowlist: [...admission.policy.egressAllowlist] },
38214
38333
  limits: { ...admission.limits }
38215
38334
  };
@@ -38350,6 +38469,7 @@ function createReceiptService(store) {
38350
38469
  runtimeImageDigest: input.admission.runtimeImageDigest,
38351
38470
  bundleDigest: input.admission.bundleDigest,
38352
38471
  dependencyLayerTag: input.admission.dependencyLayerTag,
38472
+ ...input.admission.executionContract ? { executionContract: { ...input.admission.executionContract } } : {},
38353
38473
  policy: input.admission.policy,
38354
38474
  limits: input.admission.limits,
38355
38475
  exitCode: null,
@@ -41823,7 +41943,7 @@ class EcsDispatcher {
41823
41943
  }
41824
41944
  cancellationReceipt(receipt, admission, attempt) {
41825
41945
  try {
41826
- return !!receipt && receipt.runId === admission.runId && receipt.attemptId === attempt.attemptId && !!attempt.clientToken && receipt.clientToken === attempt.clientToken && !!attempt.requestDigest && receipt.requestDigest === attempt.requestDigest && !!attempt.startedBy && receipt.startedBy === attempt.startedBy && receipt.bundleDigest === admission.bundleDigest && receipt.runtimeImageDigest === admission.runtimeImageDigest && receipt.dependencyLayerTag === admission.dependencyLayerTag && canonicalJson(receipt.policy) === canonicalJson(admission.policy) && canonicalJson(receipt.limits) === canonicalJson(admission.limits) && (receipt.taskId === null || receipt.taskId === attempt.taskId) && (receipt.status === null && receipt.completedAt === null || receipt.status === "cancelled" && typeof receipt.completedAt === "string" && Number.isFinite(Date.parse(receipt.completedAt)));
41946
+ return !!receipt && receipt.runId === admission.runId && receipt.attemptId === attempt.attemptId && !!attempt.clientToken && receipt.clientToken === attempt.clientToken && !!attempt.requestDigest && receipt.requestDigest === attempt.requestDigest && !!attempt.startedBy && receipt.startedBy === attempt.startedBy && receipt.bundleDigest === admission.bundleDigest && receipt.runtimeImageDigest === admission.runtimeImageDigest && receipt.dependencyLayerTag === admission.dependencyLayerTag && canonicalJson(receipt.executionContract ?? null) === canonicalJson(admission.executionContract ?? null) && canonicalJson(receipt.policy) === canonicalJson(admission.policy) && canonicalJson(receipt.limits) === canonicalJson(admission.limits) && (receipt.taskId === null || receipt.taskId === attempt.taskId) && (receipt.status === null && receipt.completedAt === null || receipt.status === "cancelled" && typeof receipt.completedAt === "string" && Number.isFinite(Date.parse(receipt.completedAt)));
41827
41947
  } catch {
41828
41948
  return false;
41829
41949
  }
@@ -41920,7 +42040,174 @@ function validText(value) {
41920
42040
  }
41921
42041
 
41922
42042
  // src/server/runtime-policy.ts
41923
- import { createHash as createHash10, createHmac as createHmac3, timingSafeEqual as timingSafeEqual2 } from "crypto";
42043
+ import { createHash as createHash11, createHmac as createHmac3, timingSafeEqual as timingSafeEqual2 } from "crypto";
42044
+
42045
+ // src/server/runtime-pure-contract.ts
42046
+ import { createHash as createHash10 } from "crypto";
42047
+ var PURE_DESCRIPTOR = Object.freeze({
42048
+ id: "regex-test.v1",
42049
+ runtime: "bun",
42050
+ adapter: "skills-input-json.v1",
42051
+ input: Object.freeze({ patternBytes: 512, textBytes: 4096, totalBytes: 8192, flags: "dgimsuvy" }),
42052
+ output: "regex-matches.v1",
42053
+ secrets: "none",
42054
+ egress: "deny",
42055
+ maxDurationMs: 5000,
42056
+ maxMemoryMb: 512,
42057
+ maxCpuUnits: 256,
42058
+ stdoutBytes: 16384,
42059
+ stderrBytes: 8192,
42060
+ artifactsBytes: 0
42061
+ });
42062
+ var PURE_DESCRIPTOR_DIGEST = digestInput(PURE_DESCRIPTOR);
42063
+ var PURE_LIMITS = Object.freeze({
42064
+ maxDurationMs: 5000,
42065
+ maxMemoryMb: 512,
42066
+ maxCpuUnits: 256,
42067
+ maxArtifactsBytes: 0,
42068
+ maxConcurrency: 1
42069
+ });
42070
+ var hash = (bytes) => createHash10("sha256").update(bytes).digest("hex");
42071
+ var object2 = (v2) => {
42072
+ if (!v2 || typeof v2 !== "object" || Array.isArray(v2))
42073
+ throw Error("Pure contract requires an object");
42074
+ return v2;
42075
+ };
42076
+ function keys2(v2, allowed) {
42077
+ if (Object.keys(v2).some((k4) => !allowed.includes(k4)))
42078
+ throw Error("Unsupported pure contract field");
42079
+ }
42080
+ function uniqueJson(text) {
42081
+ const result = JSON.parse(text);
42082
+ let i4 = 0;
42083
+ const ws = () => {
42084
+ while (/\s/.test(text[i4] ?? "") && i4 < text.length)
42085
+ i4++;
42086
+ };
42087
+ const string = () => {
42088
+ const start = i4++;
42089
+ while (i4 < text.length) {
42090
+ const c4 = text[i4++];
42091
+ if (c4 === "\\")
42092
+ i4++;
42093
+ else if (c4 === '"')
42094
+ break;
42095
+ }
42096
+ return JSON.parse(text.slice(start, i4));
42097
+ };
42098
+ const value = (depth) => {
42099
+ if (depth > 32)
42100
+ throw Error("JSON nesting limit exceeded");
42101
+ ws();
42102
+ if (text[i4] === '"') {
42103
+ string();
42104
+ return;
42105
+ }
42106
+ if (text[i4] === "{") {
42107
+ i4++;
42108
+ ws();
42109
+ const seen = new Set;
42110
+ if (text[i4] === "}") {
42111
+ i4++;
42112
+ return;
42113
+ }
42114
+ for (;; ) {
42115
+ ws();
42116
+ const key = string();
42117
+ if (seen.has(key))
42118
+ throw Error("Duplicate JSON key");
42119
+ seen.add(key);
42120
+ ws();
42121
+ i4++;
42122
+ value(depth + 1);
42123
+ ws();
42124
+ if (text[i4++] === "}")
42125
+ return;
42126
+ }
42127
+ }
42128
+ if (text[i4] === "[") {
42129
+ i4++;
42130
+ ws();
42131
+ if (text[i4] === "]") {
42132
+ i4++;
42133
+ return;
42134
+ }
42135
+ for (;; ) {
42136
+ value(depth + 1);
42137
+ ws();
42138
+ if (text[i4++] === "]")
42139
+ return;
42140
+ }
42141
+ }
42142
+ while (i4 < text.length && !/[\s,\]}]/.test(text[i4]))
42143
+ i4++;
42144
+ };
42145
+ value(0);
42146
+ return result;
42147
+ }
42148
+ function pureInput(value) {
42149
+ const v2 = object2(value);
42150
+ keys2(v2, ["pattern", "text", "flags"]);
42151
+ if (typeof v2.pattern !== "string" || Buffer.byteLength(v2.pattern) > 512 || typeof v2.text !== "string" || Buffer.byteLength(v2.text) > 4096 || typeof v2.flags !== "string" || !/^[dgimsuvy]*$/.test(v2.flags) || new Set(v2.flags).size !== v2.flags.length || v2.flags.includes("u") && v2.flags.includes("v") || Buffer.byteLength(JSON.stringify(v2)) > 8192)
42152
+ throw Error("Invalid pure regex input");
42153
+ return { pattern: v2.pattern, text: v2.text, flags: v2.flags };
42154
+ }
42155
+ function assertPureContract(value) {
42156
+ const v2 = object2(value);
42157
+ keys2(v2, ["id", "descriptorDigest", "entrypoint", "entrypointDigest"]);
42158
+ if (v2.id !== PURE_DESCRIPTOR.id || v2.descriptorDigest !== PURE_DESCRIPTOR_DIGEST || typeof v2.entrypoint !== "string" || !/^src\/[a-zA-Z0-9_/-]+\.[cm]?[jt]s$/.test(v2.entrypoint) || v2.entrypoint.includes("//") || typeof v2.entrypointDigest !== "string" || !/^[a-f0-9]{64}$/.test(v2.entrypointDigest))
42159
+ throw Error("Unsupported pure execution contract");
42160
+ }
42161
+ function assertPureAdmission(a4) {
42162
+ assertPureContract(a4.executionContract);
42163
+ if (a4.runtime !== "bun" || a4.dependencyLayerTag !== null || a4.policy.egress !== "deny" || a4.policy.egressAllowlist.length !== 0 || a4.policy.networkByteCap !== 0 || Object.entries(PURE_LIMITS).some(([k4, v2]) => a4.limits[k4] !== v2))
42164
+ throw Error("Pure admission isolation policy mismatch");
42165
+ return a4.executionContract;
42166
+ }
42167
+ async function inspectPureBundle(bytes, contract) {
42168
+ assertPureContract(contract);
42169
+ const { entries } = await inspectSkillBundle(bytes, { limits: {
42170
+ compressedBytes: 1e6,
42171
+ decompressedBytes: 4000000,
42172
+ fileBytes: 1e6,
42173
+ entries: 100,
42174
+ timeoutMs: 5000
42175
+ } });
42176
+ const entry = entries.find((e4) => e4.path === contract.entrypoint);
42177
+ if (!entry || hash(entry.bytes) !== contract.entrypointDigest)
42178
+ throw Error("Pure entrypoint integrity failed");
42179
+ if (entries.some((e4) => e4.path.split("/").includes("node_modules")))
42180
+ throw Error("Pure bundle cannot supply dependencies");
42181
+ const read = (name) => {
42182
+ const entry2 = entries.find((e4) => e4.path === name);
42183
+ if (!entry2)
42184
+ throw Error("Pure bundle manifest missing");
42185
+ return object2(uniqueJson(Buffer.from(entry2.bytes).toString("utf8")));
42186
+ };
42187
+ const manifest = read("skill.json"), runtime = object2(manifest.runtime), pkg = read("package.json");
42188
+ if (manifest.kind !== "executable" || runtime.runtime !== "bun" || runtime.entrypoint !== contract.entrypoint || !Array.isArray(runtime.env) || runtime.env.length !== 0 || runtime.system_deps !== undefined && (!Array.isArray(runtime.system_deps) || runtime.system_deps.length !== 0) || runtime.needs_network === true || manifest.needs_network === true)
42189
+ throw Error("Pure bundle requires a credential-free self-contained Bun entrypoint");
42190
+ for (const key of ["dependencies", "optionalDependencies", "peerDependencies", "devDependencies", "scripts"])
42191
+ if (pkg[key] !== undefined && Object.keys(object2(pkg[key])).length)
42192
+ throw Error("Pure bundle cannot install dependencies or lifecycle scripts");
42193
+ return entries;
42194
+ }
42195
+ function validatePureOutput(stdout, input) {
42196
+ if (Buffer.byteLength(stdout) > PURE_DESCRIPTOR.stdoutBytes)
42197
+ throw Error("Pure output limit exceeded");
42198
+ const v2 = object2(uniqueJson(stdout));
42199
+ keys2(v2, ["pattern", "flags", "matches"]);
42200
+ if (v2.pattern !== input.pattern || v2.flags !== input.flags || !Array.isArray(v2.matches))
42201
+ throw Error("Pure output does not match admitted input");
42202
+ for (const value of v2.matches) {
42203
+ const m2 = object2(value);
42204
+ keys2(m2, ["match", "groups", "namedGroups", "index"]);
42205
+ if (typeof m2.match !== "string" || !Number.isInteger(m2.index) || m2.index < 0 || m2.index > input.text.length || input.text.slice(m2.index, m2.index + m2.match.length) !== m2.match || !Array.isArray(m2.groups) || m2.groups.some((x2) => x2 !== null && typeof x2 !== "string") || m2.namedGroups !== null && Object.values(object2(m2.namedGroups)).some((x2) => typeof x2 !== "string"))
42206
+ throw Error("Invalid pure match output");
42207
+ }
42208
+ }
42209
+
42210
+ // src/server/runtime-policy.ts
41924
42211
  var RUNTIME_MAX_INPUT_BYTES = 1e5;
41925
42212
  var RUNTIME_MAX_BUNDLE_BYTES = 1e6;
41926
42213
  var RUNTIME_MAX_ARTIFACT_BYTES = 2000000;
@@ -41955,8 +42242,25 @@ function readRuntimeConfig(env = process.env) {
41955
42242
  for (const key of ["subnets", "securityGroups"])
41956
42243
  if (!Array.isArray(c4[key]) || !c4[key].length || c4[key].some((v2) => typeof v2 !== "string" || !v2))
41957
42244
  throw Error(`Runtime configuration missing ${key}`);
41958
- if (!Array.isArray(c4.reviewedBundles) || !c4.reviewedBundles.length || c4.reviewedBundles.some((b4) => b4.slug !== "pdf-generate" || typeof b4.version !== "string" || !/^\d+\.\d+\.\d+$/.test(b4.version) || !/^[a-f0-9]{64}$/.test(b4.sha256)))
41959
- throw Error("Runtime requires exact reviewed pdf-generate versions and bundle digests");
42245
+ if (!Array.isArray(c4.reviewedBundles) || !c4.reviewedBundles.length)
42246
+ throw Error("Runtime requires exact reviewed versions and bundle digests");
42247
+ const seen = new Set;
42248
+ for (const b4 of c4.reviewedBundles) {
42249
+ if (!b4 || typeof b4.slug !== "string" || !/^[a-z0-9-]+$/.test(b4.slug) || typeof b4.version !== "string" || !/^\d+\.\d+\.\d+$/.test(b4.version) || !/^[a-f0-9]{64}$/.test(b4.sha256))
42250
+ throw Error("Runtime requires exact reviewed versions and bundle digests");
42251
+ if (b4.executionContract) {
42252
+ if (b4.slug === "pdf-generate")
42253
+ throw Error("pdf-generate is reserved for the legacy PDF contract");
42254
+ assertPureContract(b4.executionContract);
42255
+ if (typeof b4.tenantId !== "string" || !b4.tenantId.trim() || b4.imageDigest !== c4.imageDigest)
42256
+ throw Error("Pure runtime review must bind tenant and configured image");
42257
+ } else if (b4.slug !== "pdf-generate")
42258
+ throw Error("Missing reviewed execution contract");
42259
+ const key = JSON.stringify([b4.executionContract ? b4.tenantId : null, b4.slug, b4.version, b4.sha256]);
42260
+ if (seen.has(key))
42261
+ throw Error("Ambiguous runtime review");
42262
+ seen.add(key);
42263
+ }
41960
42264
  return c4;
41961
42265
  }
41962
42266
  function runtimeInput(value) {
@@ -41992,7 +42296,7 @@ function verifyRuntimeToken(token, key, admission, attempt, now = Date.now()) {
41992
42296
  const actual = Buffer.from(token);
41993
42297
  return actual.length === expected.length && timingSafeEqual2(actual, expected);
41994
42298
  }
41995
- var hashBytes = (bytes) => createHash10("sha256").update(bytes).digest("hex");
42299
+ var hashBytes = (bytes) => createHash11("sha256").update(bytes).digest("hex");
41996
42300
 
41997
42301
  // src/server/runtime-api.ts
41998
42302
  async function createRuntimeService(options) {
@@ -42068,7 +42372,7 @@ async function body3(request, max) {
42068
42372
  }
42069
42373
  chunks.push(v2.value);
42070
42374
  }
42071
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
42375
+ return uniqueJson(Buffer.concat(chunks).toString("utf8"));
42072
42376
  }
42073
42377
  var scope = (p2, action) => p2.scopes.includes("*") || p2.scopes.includes("runs:*") || p2.scopes.includes(action) || action === "runs:read" && p2.scopes.includes("skills:read");
42074
42378
  function publicJob(job) {
@@ -42082,6 +42386,7 @@ function publicJob(job) {
42082
42386
  bundleDigest: a4.bundleDigest,
42083
42387
  inputDigest: a4.inputDigest,
42084
42388
  runtimeImageDigest: a4.runtimeImageDigest,
42389
+ executionContract: a4.executionContract ?? { id: "pdf.v1" },
42085
42390
  status: job.execution.status,
42086
42391
  createdAt: a4.createdAt,
42087
42392
  updatedAt: job.execution.updatedAt,
@@ -42092,6 +42397,33 @@ function publicJob(job) {
42092
42397
  artifacts: (job.result?.artifacts ?? []).map(({ base64, ...meta }) => meta)
42093
42398
  };
42094
42399
  }
42400
+ function findReview(service, tenant, slug, version2, sha256) {
42401
+ return service.config.reviewedBundles.find((b4) => b4.slug === slug && b4.version === version2 && b4.sha256 === sha256 && (!b4.executionContract || b4.tenantId === tenant && b4.imageDigest === service.config.imageDigest));
42402
+ }
42403
+ function sameContract(admission, contract, image) {
42404
+ return digestInput(admission.executionContract ?? null) === digestInput(contract ?? null) && (!contract || admission.runtimeImageDigest === image);
42405
+ }
42406
+
42407
+ class RuntimeBundleError extends Error {
42408
+ code;
42409
+ status;
42410
+ constructor(code, status) {
42411
+ super(code);
42412
+ this.code = code;
42413
+ this.status = status;
42414
+ }
42415
+ }
42416
+ async function reviewedBytes(service, principal, digest2) {
42417
+ const bundle = await service.productStore.getSkillBundle(principal, digest2);
42418
+ if (!bundle)
42419
+ throw new RuntimeBundleError("BUNDLE_UNAVAILABLE", 503);
42420
+ if (bundle.byteSize > RUNTIME_MAX_BUNDLE_BYTES)
42421
+ throw new RuntimeBundleError("BUNDLE_LIMIT", 413);
42422
+ const bytes = await service.artifacts.readBundle(bundle);
42423
+ if (!bytes || bytes.length > RUNTIME_MAX_BUNDLE_BYTES || hashBytes(bytes) !== digest2)
42424
+ throw new RuntimeBundleError("BUNDLE_INTEGRITY_FAILED", 503);
42425
+ return bytes;
42426
+ }
42095
42427
  async function handleRuntimeApiRequest(request, principal, service) {
42096
42428
  const segments = path(request);
42097
42429
  if (segments[0] !== "api" || segments[1] !== "v1" || segments[2] !== "executions")
@@ -42104,9 +42436,50 @@ async function handleRuntimeApiRequest(request, principal, service) {
42104
42436
  if (!id || segments.length > 6 || !/^[a-z0-9_-]+$/.test(id))
42105
42437
  return refusal("INVALID_EXECUTION_PATH", 400);
42106
42438
  try {
42439
+ if (request.method === "GET" && sub === "eligibility" && segments.length === 5) {
42440
+ const query = new URL(request.url).searchParams;
42441
+ const versionName = query.get("version");
42442
+ if (!versionName || !/^\d+\.\d+\.\d+$/.test(versionName))
42443
+ return refusal("REVIEWED_VERSION_REQUIRED", 400);
42444
+ const skill = await service.productStore.getSkill(principal, id);
42445
+ if (skill?.lifecycle === "archived")
42446
+ return refusal("SKILL_ARCHIVED", 410);
42447
+ const version2 = skill && !skill.tombstonedAt && await service.productStore.getSkillVersion(principal, id, versionName);
42448
+ if (!version2)
42449
+ return refusal("SKILL_VERSION_NOT_FOUND", 404);
42450
+ const pinned = query.get("bundleDigest");
42451
+ if (pinned && pinned.replace(/^sha256:/, "") !== version2.bundleSha256)
42452
+ return refusal("SELECTION_DIGEST_MISMATCH", 409);
42453
+ const review = findReview(service, principal.orgId, id, versionName, version2.bundleSha256);
42454
+ if (!review)
42455
+ return json3({
42456
+ contractVersion: 1,
42457
+ eligible: false,
42458
+ reason: "BUNDLE_NOT_REVIEWED_FOR_CLOUD",
42459
+ skill: id,
42460
+ version: versionName,
42461
+ bundleDigest: version2.bundleSha256
42462
+ });
42463
+ if (review.executionContract) {
42464
+ const bytes = await reviewedBytes(service, principal, version2.bundleSha256);
42465
+ await inspectPureBundle(bytes, review.executionContract);
42466
+ }
42467
+ return json3({
42468
+ contractVersion: 1,
42469
+ eligible: true,
42470
+ skill: id,
42471
+ version: versionName,
42472
+ bundleDigest: version2.bundleSha256,
42473
+ runtimeImageDigest: service.config.imageDigest,
42474
+ executionContract: review.executionContract ?? { id: "pdf.v1" },
42475
+ secrets: "none",
42476
+ egress: "deny",
42477
+ ...review.executionContract ? { descriptor: PURE_DESCRIPTOR, limits: PURE_LIMITS } : {}
42478
+ });
42479
+ }
42107
42480
  if (request.method === "POST" && !sub) {
42108
42481
  const raw = await body3(request, 150000);
42109
- if (id !== "pdf-generate" || typeof raw.version !== "string" || !/^\d+\.\d+\.\d+$/.test(raw.version))
42482
+ if (typeof raw.version !== "string" || !/^\d+\.\d+\.\d+$/.test(raw.version))
42110
42483
  return refusal("REVIEWED_VERSION_REQUIRED", 400);
42111
42484
  if (Object.keys(raw).some((k4) => ![
42112
42485
  "version",
@@ -42118,31 +42491,32 @@ async function handleRuntimeApiRequest(request, principal, service) {
42118
42491
  return refusal("UNSUPPORTED_EXECUTION_FIELD", 400);
42119
42492
  if (raw.workspaceId !== undefined && raw.workspaceId !== principal.orgId)
42120
42493
  return refusal("SELECTION_WORKSPACE_MISMATCH", 409);
42121
- const input = runtimeInput(raw.input);
42122
42494
  const key = request.headers.get("idempotency-key") ?? raw.idempotencyKey;
42123
42495
  if (typeof key !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(key))
42124
42496
  return refusal("IDEMPOTENCY_KEY_REQUIRED", 400);
42125
42497
  const skill = await service.productStore.getSkill(principal, id);
42126
42498
  if (!skill || skill.tombstonedAt)
42127
42499
  return refusal("SKILL_NOT_FOUND", 404);
42500
+ if (skill.lifecycle === "archived")
42501
+ return refusal("SKILL_ARCHIVED", 410);
42128
42502
  const version2 = await service.productStore.getSkillVersion(principal, id, raw.version);
42129
42503
  if (!version2)
42130
42504
  return refusal("SKILL_VERSION_NOT_FOUND", 404);
42131
42505
  if (raw.bundleDigest !== undefined && (typeof raw.bundleDigest !== "string" || raw.bundleDigest.replace(/^sha256:/, "") !== version2.bundleSha256))
42132
42506
  return refusal("SELECTION_DIGEST_MISMATCH", 409);
42133
- if (!service.config.reviewedBundles.some((b4) => b4.slug === id && b4.version === raw.version && b4.sha256 === version2.bundleSha256))
42507
+ const review = findReview(service, principal.orgId, id, raw.version, version2.bundleSha256);
42508
+ if (!review)
42134
42509
  return refusal("BUNDLE_NOT_REVIEWED_FOR_CLOUD", 403);
42510
+ const contract = review.executionContract;
42511
+ if (contract && (raw.workspaceId !== principal.orgId || typeof raw.bundleDigest !== "string"))
42512
+ return refusal("PURE_SELECTION_REQUIRED", 400);
42513
+ const input = contract ? pureInput(raw.input) : runtimeInput(raw.input);
42135
42514
  const existing = await service.store.getRunByKey(principal.orgId, key);
42136
- if (existing && (existing.admission.skillId !== id || existing.admission.skillVersion !== raw.version || existing.admission.bundleDigest !== version2.bundleSha256 || existing.admission.inputDigest !== digestInput(input)))
42515
+ if (existing && (existing.admission.skillId !== id || existing.admission.skillVersion !== raw.version || existing.admission.bundleDigest !== version2.bundleSha256 || existing.admission.inputDigest !== digestInput(input) || !sameContract(existing.admission, contract, service.config.imageDigest)))
42137
42516
  return refusal("IDEMPOTENCY_CONFLICT", 409);
42138
- const bundle = await service.productStore.getSkillBundle(principal, version2.bundleSha256);
42139
- if (!bundle)
42140
- return refusal("BUNDLE_UNAVAILABLE", 503);
42141
- if (bundle.byteSize > RUNTIME_MAX_BUNDLE_BYTES)
42142
- return refusal("BUNDLE_LIMIT", 413);
42143
- const bytes = await service.artifacts.readBundle(bundle);
42144
- if (!bytes || hashBytes(bytes) !== version2.bundleSha256)
42145
- return refusal("BUNDLE_INTEGRITY_FAILED", 503);
42517
+ const bytes = await reviewedBytes(service, principal, version2.bundleSha256);
42518
+ if (contract)
42519
+ await inspectPureBundle(bytes, contract);
42146
42520
  const admission = createSubmitRunService({
42147
42521
  store: service.store,
42148
42522
  imageProfiles: createImageProfileRegistry({
@@ -42164,12 +42538,13 @@ async function handleRuntimeApiRequest(request, principal, service) {
42164
42538
  input,
42165
42539
  idempotencyKey: key,
42166
42540
  runtime: "bun",
42167
- limits: {
42541
+ ...contract ? { executionContract: contract } : {},
42542
+ limits: contract ? PURE_LIMITS : {
42168
42543
  maxDurationMs: RUNTIME_TIMEOUT_MS,
42169
42544
  maxArtifactsBytes: RUNTIME_MAX_ARTIFACT_BYTES
42170
42545
  }
42171
42546
  });
42172
- if (run.tenantId !== principal.orgId || run.skillId !== id || run.skillVersion !== raw.version || run.bundleDigest !== version2.bundleSha256 || run.inputDigest !== digestInput(input))
42547
+ if (run.tenantId !== principal.orgId || run.skillId !== id || run.skillVersion !== raw.version || run.bundleDigest !== version2.bundleSha256 || run.inputDigest !== digestInput(input) || !sameContract(run, contract, service.config.imageDigest))
42173
42548
  return refusal("IDEMPOTENCY_CONFLICT", 409);
42174
42549
  await service.store.mutate(run.runId, (j4) => {
42175
42550
  if (!j4.input) {
@@ -42261,6 +42636,8 @@ async function handleRuntimeApiRequest(request, principal, service) {
42261
42636
  }
42262
42637
  return refusal("EXECUTION_ROUTE_NOT_FOUND", 404);
42263
42638
  } catch (error) {
42639
+ if (error instanceof RuntimeBundleError)
42640
+ return refusal(error.code, error.status);
42264
42641
  if (error.message === "Runtime concurrency limit reached")
42265
42642
  return refusal("RUNTIME_CONCURRENCY_LIMIT", 429);
42266
42643
  return refusal("EXECUTION_REQUEST_FAILED", 400, error.message.startsWith("PDF ") ? error.message : "Execution request could not be completed");
@@ -42291,7 +42668,7 @@ async function handleRuntimeWorkerRequest(request, service) {
42291
42668
  }
42292
42669
  if (request.method === "POST" && s2[4] === "complete") {
42293
42670
  try {
42294
- const result = validatedResult(await body3(request, 3000000));
42671
+ const result = validatedResult(await body3(request, 3000000), job);
42295
42672
  await service.store.mutate(s2[3], (j4) => {
42296
42673
  if (j4.execution.status === "cancelled")
42297
42674
  throw Error("Execution cancelled");
@@ -42328,12 +42705,28 @@ async function handleRuntimeWorkerRequest(request, service) {
42328
42705
  }
42329
42706
  return refusal("METHOD_NOT_ALLOWED", 405);
42330
42707
  }
42331
- function validatedResult(value) {
42708
+ function validatedResult(value, job) {
42332
42709
  if (!value || typeof value !== "object")
42333
42710
  throw Error("Invalid result");
42334
42711
  const v2 = value;
42335
42712
  if (!Number.isInteger(v2.exitCode) || v2.exitCode < 0 || v2.exitCode > 255 || typeof v2.stdout !== "string" || typeof v2.stderr !== "string" || Buffer.byteLength(v2.stdout) > RUNTIME_MAX_LOG_BYTES || Buffer.byteLength(v2.stderr) > RUNTIME_MAX_LOG_BYTES || !Array.isArray(v2.artifacts) || v2.artifacts.length > 2)
42336
42713
  throw Error("Invalid result");
42714
+ if (job.execution.admission.executionContract) {
42715
+ assertPureAdmission(job.execution.admission);
42716
+ if (v2.artifacts.length || Buffer.byteLength(v2.stdout) > PURE_DESCRIPTOR.stdoutBytes || Buffer.byteLength(v2.stderr) > PURE_DESCRIPTOR.stderrBytes)
42717
+ throw Error("Invalid pure result limits");
42718
+ if (v2.exitCode === 0)
42719
+ validatePureOutput(v2.stdout, pureInput(job.input));
42720
+ return {
42721
+ exitCode: v2.exitCode,
42722
+ stdout: v2.stdout,
42723
+ stderr: v2.stderr,
42724
+ artifacts: [],
42725
+ ...typeof v2.error === "string" ? { error: v2.error.slice(0, 500) } : {}
42726
+ };
42727
+ }
42728
+ if (job.execution.admission.skillId !== "pdf-generate")
42729
+ throw Error("Missing execution contract");
42337
42730
  let total = 0;
42338
42731
  const seen = new Set;
42339
42732
  for (const a4 of v2.artifacts) {
@@ -42510,8 +42903,22 @@ class MemorySkillsStore {
42510
42903
  async ensureBootstrapApiKey(token, principal) {
42511
42904
  this.addApiKey(token, principal);
42512
42905
  }
42513
- async authenticateApiKeyHash(hash) {
42514
- return this.apiKeys.get(hash) ?? null;
42906
+ async authenticateApiKeyHash(hash2) {
42907
+ return this.apiKeys.get(hash2) ?? null;
42908
+ }
42909
+ async updateApiKeyScopes(actor, keyId, expectedScopes, addScopes) {
42910
+ for (const [hash2, target] of this.apiKeys) {
42911
+ if (target.apiKeyId !== keyId || target.orgId !== actor.orgId)
42912
+ continue;
42913
+ if (target.scopes.length !== expectedScopes.length || target.scopes.some((scope2, index) => scope2 !== expectedScopes[index])) {
42914
+ return { kind: "stale", scopes: [...target.scopes] };
42915
+ }
42916
+ const scopes = [...target.scopes, ...addScopes.filter((scope2) => !target.scopes.includes(scope2))];
42917
+ const updated = { ...target, scopes };
42918
+ this.apiKeys.set(hash2, updated);
42919
+ return { kind: "updated", scopes };
42920
+ }
42921
+ return { kind: "not_found" };
42515
42922
  }
42516
42923
  async createRun(input) {
42517
42924
  const idemKey = input.idempotencyKey?.trim();
@@ -42642,7 +43049,11 @@ class MemorySkillsStore {
42642
43049
  createdAt: previous?.createdAt ?? now,
42643
43050
  updatedAt: now,
42644
43051
  revisionId: revisionIdOfRecord(recordFieldsOf(input, carriedBundle, carriedSkillMd)),
42645
- revisionNumber: (previous?.revisionNumber ?? 0) + 1
43052
+ revisionNumber: (previous?.revisionNumber ?? 0) + 1,
43053
+ lifecycle: previous?.lifecycle ?? "active",
43054
+ ...previous?.archivedAt ? { archivedAt: previous.archivedAt } : {},
43055
+ ...previous?.archiveReason ? { archiveReason: previous.archiveReason } : {},
43056
+ ...previous?.replacementSlug ? { replacementSlug: previous.replacementSlug } : {}
42646
43057
  };
42647
43058
  this.skills.set(key, record);
42648
43059
  if (input.version && versionSha && !this.versions.has(versionKey(input.principal.orgId, input.slug, input.version))) {
@@ -42678,6 +43089,33 @@ class MemorySkillsStore {
42678
43089
  const skill = this.skills.get(skillKey(principal.orgId, slug));
42679
43090
  return skill && skill.orgId === principal.orgId ? skill : null;
42680
43091
  }
43092
+ async setSkillLifecycle(principal, slug, patch, expectedRevisionId) {
43093
+ const current = await this.getSkill(principal, slug);
43094
+ if (!current || current.tombstonedAt)
43095
+ return null;
43096
+ if (expectedRevisionId !== current.revisionId)
43097
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
43098
+ if (patch.lifecycle === "archived" && current.lifecycle !== "archived") {
43099
+ const profiles = await this.selectionStore.profilesReferencingSkill(principal, slug);
43100
+ if (profiles.length)
43101
+ throw new SkillLifecycleConflictError(slug, profiles);
43102
+ }
43103
+ const next2 = {
43104
+ ...current,
43105
+ lifecycle: patch.lifecycle,
43106
+ updatedAt: nowIso(),
43107
+ revisionId: revisionIdOfRecord({ ...current, lifecycle: patch.lifecycle, archiveReason: patch.reason, replacementSlug: patch.replacementSlug }),
43108
+ revisionNumber: current.revisionNumber + 1,
43109
+ ...patch.lifecycle === "archived" ? { archivedAt: current.archivedAt ?? nowIso(), ...patch.reason ? { archiveReason: patch.reason } : {}, ...patch.replacementSlug ? { replacementSlug: patch.replacementSlug } : {} } : {}
43110
+ };
43111
+ if (patch.lifecycle === "active") {
43112
+ delete next2.archivedAt;
43113
+ delete next2.archiveReason;
43114
+ delete next2.replacementSlug;
43115
+ }
43116
+ this.skills.set(skillKey(principal.orgId, slug), next2);
43117
+ return next2;
43118
+ }
42681
43119
  async updateSkill(principal, slug, patch, expectedRevisionId) {
42682
43120
  const current = await this.getSkill(principal, slug);
42683
43121
  if (!current || current.tombstonedAt)
@@ -42748,7 +43186,7 @@ class MemorySkillsStore {
42748
43186
  await this.purgeExpiredTombstones(principal);
42749
43187
  const tags = new Set;
42750
43188
  for (const skill of this.skills.values()) {
42751
- if (skill.orgId !== principal.orgId)
43189
+ if (skill.orgId !== principal.orgId || skill.lifecycle !== "active")
42752
43190
  continue;
42753
43191
  for (const tag of skill.tags) {
42754
43192
  if (tag.trim())
@@ -42759,13 +43197,13 @@ class MemorySkillsStore {
42759
43197
  }
42760
43198
  async listSkillsByTag(principal, tag) {
42761
43199
  await this.purgeExpiredTombstones(principal);
42762
- return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId && !skill.tombstonedAt && skill.tags.includes(tag)).sort((a4, b4) => a4.slug.localeCompare(b4.slug));
43200
+ return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId && !skill.tombstonedAt && skill.lifecycle === "active" && skill.tags.includes(tag)).sort((a4, b4) => a4.slug.localeCompare(b4.slug));
42763
43201
  }
42764
43202
  async listPinsByTag(principal, tag) {
42765
43203
  await this.purgeExpiredTombstones(principal);
42766
43204
  const taggedSlugs = new Set;
42767
43205
  for (const skill of this.skills.values()) {
42768
- if (skill.orgId === principal.orgId && !skill.tombstonedAt && skill.tags.includes(tag))
43206
+ if (skill.orgId === principal.orgId && !skill.tombstonedAt && skill.lifecycle === "active" && skill.tags.includes(tag))
42769
43207
  taggedSlugs.add(skill.slug);
42770
43208
  }
42771
43209
  return Array.from(this.pins.values()).filter((pin) => pin.orgId === principal.orgId && pin.principal === principal.apiKeyId && taggedSlugs.has(pin.slug)).sort((a4, b4) => a4.slug.localeCompare(b4.slug));
@@ -42856,7 +43294,7 @@ class PostgresSkillsStore {
42856
43294
  ON CONFLICT (key_hash) DO NOTHING
42857
43295
  `;
42858
43296
  }
42859
- async authenticateApiKeyHash(hash) {
43297
+ async authenticateApiKeyHash(hash2) {
42860
43298
  const rows = await this.sql`
42861
43299
  SELECT k.id AS api_key_id, k.scopes_json, o.id AS org_id, o.slug AS org_slug, o.name AS org_name,
42862
43300
  u.id AS user_id, u.email, m.role
@@ -42864,7 +43302,7 @@ class PostgresSkillsStore {
42864
43302
  JOIN organizations o ON o.id = k.org_id
42865
43303
  JOIN users u ON u.id = k.user_id
42866
43304
  LEFT JOIN organization_members m ON m.org_id = k.org_id AND m.user_id = k.user_id
42867
- WHERE k.key_hash = ${hash} AND k.revoked_at IS NULL
43305
+ WHERE k.key_hash = ${hash2} AND k.revoked_at IS NULL
42868
43306
  LIMIT 1
42869
43307
  `;
42870
43308
  const row = rows[0];
@@ -42882,6 +43320,38 @@ class PostgresSkillsStore {
42882
43320
  scopes: parseJsonArray(row.scopes_json)
42883
43321
  };
42884
43322
  }
43323
+ async updateApiKeyScopes(actor, keyId, expectedScopes, addScopes) {
43324
+ return this.sql.begin(async (tx) => {
43325
+ const rows = await tx`
43326
+ SELECT scopes_json FROM api_keys
43327
+ WHERE id = ${keyId} AND org_id = ${actor.orgId} AND revoked_at IS NULL
43328
+ LIMIT 1
43329
+ `;
43330
+ const row = rows[0];
43331
+ if (!row)
43332
+ return { kind: "not_found" };
43333
+ const current = parseJsonArray(row.scopes_json);
43334
+ if (current.length !== expectedScopes.length || current.some((scope2, index) => scope2 !== expectedScopes[index])) {
43335
+ return { kind: "stale", scopes: current };
43336
+ }
43337
+ const scopes = [...current, ...addScopes.filter((scope2) => !current.includes(scope2))];
43338
+ const updated = await tx`
43339
+ UPDATE api_keys SET scopes_json = ${JSON.stringify(scopes)}::jsonb
43340
+ WHERE id = ${keyId} AND org_id = ${actor.orgId} AND revoked_at IS NULL
43341
+ AND scopes_json = ${JSON.stringify(expectedScopes)}::jsonb
43342
+ RETURNING scopes_json
43343
+ `;
43344
+ if (!updated[0]) {
43345
+ const latest = await tx`SELECT scopes_json FROM api_keys WHERE id = ${keyId} AND org_id = ${actor.orgId} AND revoked_at IS NULL LIMIT 1`;
43346
+ return { kind: "stale", scopes: latest[0] ? parseJsonArray(latest[0].scopes_json) : current };
43347
+ }
43348
+ await tx`
43349
+ INSERT INTO skills_audit_events (org_id, user_id, api_key_id, action, target_type, target_id, metadata_json)
43350
+ VALUES (${actor.orgId}, ${actor.userId}, ${actor.apiKeyId}, ${"api_key_scopes_added"}, ${"api_key"}, ${keyId}, ${JSON.stringify({ added: addScopes, scopes })}::jsonb)
43351
+ `;
43352
+ return { kind: "updated", scopes };
43353
+ });
43354
+ }
42885
43355
  async withContext(orgId, worker, fn) {
42886
43356
  return this.sql.begin(async (tx) => {
42887
43357
  await tx`SELECT set_config('app.skills_org_id', ${orgId ?? ""}, true)`;
@@ -43202,6 +43672,42 @@ class PostgresSkillsStore {
43202
43672
  const rows = await this.sql`SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1`;
43203
43673
  return rows[0] ? rowToSkill(rows[0]) : null;
43204
43674
  }
43675
+ async setSkillLifecycle(principal, slug, patch, expectedRevisionId) {
43676
+ return this.sql.begin(async (tx) => {
43677
+ const currentRows = await tx`SELECT * FROM skills_registry WHERE org_id=${principal.orgId} AND slug=${slug} LIMIT 1 FOR UPDATE`;
43678
+ const current = currentRows[0] ? rowToSkill(currentRows[0]) : null;
43679
+ if (!current || current.tombstonedAt)
43680
+ return null;
43681
+ if (expectedRevisionId !== current.revisionId)
43682
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
43683
+ if (patch.lifecycle === "archived" && current.lifecycle !== "archived") {
43684
+ const profiles = await tx`SELECT profile_id,selections_json FROM skills_profiles WHERE org_id=${principal.orgId}`;
43685
+ const referencing = profiles.filter((profile2) => {
43686
+ const selections2 = typeof profile2.selections_json === "string" ? JSON.parse(profile2.selections_json) : profile2.selections_json;
43687
+ return Array.isArray(selections2) && selections2.some((selection2) => selection2?.slug === slug);
43688
+ }).map((profile2) => String(profile2.profile_id)).sort();
43689
+ if (referencing.length)
43690
+ throw new SkillLifecycleConflictError(slug, referencing);
43691
+ }
43692
+ const next2 = {
43693
+ ...current,
43694
+ lifecycle: patch.lifecycle,
43695
+ updatedAt: nowIso(),
43696
+ revisionId: revisionIdOfRecord({ ...current, lifecycle: patch.lifecycle, archiveReason: patch.reason, replacementSlug: patch.replacementSlug }),
43697
+ revisionNumber: current.revisionNumber + 1,
43698
+ ...patch.lifecycle === "archived" ? { archivedAt: current.archivedAt ?? nowIso(), ...patch.reason ? { archiveReason: patch.reason } : {}, ...patch.replacementSlug ? { replacementSlug: patch.replacementSlug } : {} } : {}
43699
+ };
43700
+ if (patch.lifecycle === "active") {
43701
+ delete next2.archivedAt;
43702
+ delete next2.archiveReason;
43703
+ delete next2.replacementSlug;
43704
+ }
43705
+ const rows = await tx`UPDATE skills_registry SET lifecycle=${next2.lifecycle}, archived_at=${next2.archivedAt ?? null}, archive_reason=${next2.archiveReason ?? null}, replacement_slug=${next2.replacementSlug ?? null}, revision_id=${next2.revisionId}, revision_number=revision_number+1, updated_at=${next2.updatedAt} WHERE org_id=${principal.orgId} AND slug=${slug} AND tombstoned_at IS NULL AND revision_id=${current.revisionId} RETURNING *`;
43706
+ if (!rows[0])
43707
+ throw new SkillRevisionConflictError(slug, expectedRevisionId, (await this.getSkill(principal, slug))?.revisionId ?? null);
43708
+ return rowToSkill(rows[0]);
43709
+ });
43710
+ }
43205
43711
  async updateSkill(principal, slug, patch, expectedRevisionId) {
43206
43712
  const current = await this.getSkill(principal, slug);
43207
43713
  if (!current || current.tombstonedAt)
@@ -43337,7 +43843,7 @@ class PostgresSkillsStore {
43337
43843
  async listTags(principal) {
43338
43844
  await this.purgeExpiredTombstones(principal);
43339
43845
  const rows = await this.sql`
43340
- SELECT DISTINCT tag FROM skills_tags WHERE org_id = ${principal.orgId} ORDER BY tag ASC
43846
+ 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 = ${principal.orgId} AND s.lifecycle = 'active' ORDER BY t.tag ASC
43341
43847
  `;
43342
43848
  return rows.map((row) => String(row.tag));
43343
43849
  }
@@ -43346,7 +43852,7 @@ class PostgresSkillsStore {
43346
43852
  const rows = await this.sql`
43347
43853
  SELECT s.* FROM skills_registry s
43348
43854
  JOIN skills_tags t ON t.org_id = s.org_id AND t.slug = s.slug
43349
- WHERE t.org_id = ${principal.orgId} AND t.tag = ${tag} AND s.tombstoned_at IS NULL
43855
+ WHERE t.org_id = ${principal.orgId} AND t.tag = ${tag} AND s.tombstoned_at IS NULL AND s.lifecycle = 'active'
43350
43856
  ORDER BY s.slug ASC
43351
43857
  `;
43352
43858
  return rows.map(rowToSkill);
@@ -43358,7 +43864,7 @@ class PostgresSkillsStore {
43358
43864
  JOIN skills_tags t ON t.org_id = p.org_id AND t.slug = p.slug
43359
43865
  JOIN skills_registry s ON s.org_id = p.org_id AND s.slug = p.slug
43360
43866
  WHERE p.org_id = ${principal.orgId} AND p.principal = ${principal.apiKeyId}
43361
- AND t.tag = ${tag} AND s.tombstoned_at IS NULL
43867
+ AND t.tag = ${tag} AND s.tombstoned_at IS NULL AND s.lifecycle = 'active'
43362
43868
  ORDER BY p.slug ASC
43363
43869
  `;
43364
43870
  return rows.map(rowToPin);
@@ -43455,7 +43961,7 @@ async function createSkillsFetchHandler(options = {}) {
43455
43961
  if (request.method === "GET" && url.pathname === "/api/v1/capabilities") {
43456
43962
  const response = await handleProfileApi(store, principal, request, ["capabilities"], config);
43457
43963
  const payload = await response.json();
43458
- return json4({ ...payload, cloudExecution: Boolean(runtime), capabilities: [...payload.capabilities, ...runtime ? ["skills.cloud-execution"] : []] });
43964
+ return json4({ ...payload, cloudExecution: Boolean(runtime), cloudExecutionContracts: runtime ? ["pdf.v1", "regex-test.v1"] : [], capabilities: [...payload.capabilities, ...runtime ? ["skills.cloud-execution"] : []] });
43459
43965
  }
43460
43966
  if (request.method === "GET" && url.pathname === "/api/auth/whoami") {
43461
43967
  return json4(identityPayload(principal));
@@ -43479,6 +43985,9 @@ async function createSkillsFetchHandler(options = {}) {
43479
43985
  attemptedBundleSha256: error.attemptedSha256
43480
43986
  }, { status: 409 });
43481
43987
  }
43988
+ if (error instanceof SkillLifecycleConflictError) {
43989
+ return json4({ error: error.message, code: "SKILL_ARCHIVE_PROFILE_CONFLICT", profiles: error.profiles }, { status: 409 });
43990
+ }
43482
43991
  if (error instanceof SkillRevisionConflictError) {
43483
43992
  return json4({
43484
43993
  error: error.message,
@@ -43517,6 +44026,32 @@ async function handleApiV1(store, governanceStore, principal, request, parts, co
43517
44026
  if (parts.some(segmentEscapesPath)) {
43518
44027
  return json4({ error: "invalid path segment", code: "INVALID_PATH" }, { status: 400 });
43519
44028
  }
44029
+ if (parts.length === 4 && resource === "admin" && id === "keys" && subresource && childId === "scopes" && request.method === "PATCH") {
44030
+ const adminScope = principal.scopes.includes("*") || principal.scopes.includes("skills:keys.admin");
44031
+ if (!adminScope || !["owner", "admin"].includes(principal.role)) {
44032
+ return json4({ error: "Key scope administration requires an owner/admin key with skills:keys.admin", code: "KEY_ADMIN_REQUIRED" }, { status: 403 });
44033
+ }
44034
+ if (!/^[A-Za-z0-9_-]+$/.test(subresource)) {
44035
+ return json4({ error: "Key id must be url-safe (letters, digits, '_' or '-')", code: "INVALID_KEY_ID" }, { status: 400 });
44036
+ }
44037
+ const body4 = await readJson(request, config.requestBodyLimitBytes);
44038
+ const expected = body4.expected_scopes;
44039
+ const additions = body4.add_scopes;
44040
+ if (!Array.isArray(expected) || !expected.every((scope2) => typeof scope2 === "string") || !Array.isArray(additions) || !additions.every((scope2) => typeof scope2 === "string")) {
44041
+ return json4({ error: "expected_scopes and add_scopes must be arrays of scope strings", code: "INVALID_KEY_SCOPE_UPDATE" }, { status: 400 });
44042
+ }
44043
+ if (additions.length !== 1 || additions[0] !== "skills:publish") {
44044
+ return json4({ error: "Only adding skills:publish to an existing Skills key is supported", code: "INVALID_KEY_SCOPE_UPDATE" }, { status: 400 });
44045
+ }
44046
+ const update = await store.updateApiKeyScopes?.(principal, subresource, expected, additions);
44047
+ if (!update)
44048
+ return json4({ error: "Key scope administration is unavailable", code: "KEY_SCOPE_UPDATE_UNAVAILABLE" }, { status: 501 });
44049
+ if (update.kind === "not_found")
44050
+ return json4({ error: "API key not found", code: "API_KEY_NOT_FOUND" }, { status: 404 });
44051
+ if (update.kind === "stale")
44052
+ return json4({ error: "API key scopes changed; re-read and retry with expected_scopes", code: "API_KEY_SCOPE_CONFLICT", keyId: subresource, scopes: update.scopes }, { status: 409 });
44053
+ return json4({ keyId: subresource, orgId: principal.orgId, scopes: update.scopes, updated: true });
44054
+ }
43520
44055
  if (!permitsSkillsRoute(principal, request.method, resource ?? "")) {
43521
44056
  return json4({ error: "The API key does not allow this operation", code: "INSUFFICIENT_SCOPE" }, { status: 403 });
43522
44057
  }
@@ -43550,6 +44085,13 @@ async function handleApiV1(store, governanceStore, principal, request, parts, co
43550
44085
  if (request.method === "GET" && id && subresource === "versions" && !childId) {
43551
44086
  return json4(await listSkillVersionsPayload(store, principal, id));
43552
44087
  }
44088
+ if (request.method === "PATCH" && id && subresource === "lifecycle" && !childId) {
44089
+ if (!["owner", "admin"].includes(principal.role))
44090
+ return json4({ error: "Only organization owners or admins may change skill lifecycle", code: "LIFECYCLE_ROLE_REQUIRED" }, { status: 403 });
44091
+ const expectedRevisionId = parseIfMatch(request.headers.get("if-match"));
44092
+ const updated = await store.setSkillLifecycle(principal, id, skillLifecyclePatch(await readJson(request, config.requestBodyLimitBytes)), expectedRevisionId);
44093
+ return updated ? json4(publishedPayload(updated), { headers: { ETag: revisionEtag(updated.revisionId) } }) : json4({ error: "published skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
44094
+ }
43553
44095
  if (request.method === "GET" && id && subresource === "versions" && childId && !parts[4]) {
43554
44096
  const version2 = await readSkillVersion(store, principal, id, childId);
43555
44097
  const current = (await store.getSkill(principal, id))?.bundleSha256;
@@ -43708,8 +44250,11 @@ async function handleApiV1(store, governanceStore, principal, request, parts, co
43708
44250
  return json4({ error: "not found", code: "NOT_FOUND" }, { status: 404 });
43709
44251
  }
43710
44252
  function identityPayload(principal) {
44253
+ const safeKeyId = /^[A-Za-z0-9_-]{1,256}$/.test(principal.apiKeyId) ? principal.apiKeyId : undefined;
44254
+ const safeScopes = principal.scopes.length <= 32 && principal.scopes.every((scope2) => scope2.length <= 128 && /^(?:\*|[a-z][a-z0-9_-]*:(?:\*|[a-z][a-z0-9_-]*))$/.test(scope2)) ? [...new Set(principal.scopes)] : undefined;
43711
44255
  return {
43712
44256
  user: { id: principal.userId, email: principal.email, role: principal.role },
44257
+ ...safeKeyId && safeScopes ? { apiKey: { id: safeKeyId, scopes: safeScopes } } : {},
43713
44258
  organization: { id: principal.orgId, slug: principal.orgSlug, name: principal.orgName }
43714
44259
  };
43715
44260
  }