@apoa/core 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,7 +53,7 @@ const denied = await client.authorize(token, "nationwidemortgage.com", "document
53
53
  - **Cascade revocation**: revoke parent, all children die instantly
54
54
  - **Audit trail**: append-only action log per token
55
55
  - **Browser mode**: credential vault injection config (the AI never sees passwords)
56
- - **263 tests** across 17 test files
56
+ - **Comprehensive test suite** with cross-SDK fixture verification against the [Python SDK](https://pypi.org/project/apoa/)
57
57
 
58
58
  ## Two Usage Styles
59
59
 
@@ -84,8 +84,8 @@ Tokens created by `@apoa/core` validate in the [Python SDK](https://pypi.org/pro
84
84
  ## Links
85
85
 
86
86
  - [Spec](https://github.com/agenticpoa/apoa/blob/main/SPEC.md)
87
- - [Source](https://github.com/agenticpoa/apoa/tree/main/sdk)
88
- - [Examples](https://github.com/agenticpoa/apoa/tree/main/sdk/examples)
87
+ - [Source](https://github.com/agenticpoa/apoa/tree/main/sdks/typescript)
88
+ - [Examples](https://github.com/agenticpoa/apoa/tree/main/sdks/typescript/examples)
89
89
 
90
90
  ## License
91
91
 
package/dist/index.cjs CHANGED
@@ -42,16 +42,19 @@ __export(index_exports, {
42
42
  ScopeViolationError: () => ScopeViolationError,
43
43
  TokenExpiredError: () => TokenExpiredError,
44
44
  authorize: () => authorize,
45
+ buildJWKS: () => buildJWKS,
45
46
  cascadeRevoke: () => cascadeRevoke,
46
47
  checkConstraint: () => checkConstraint,
47
48
  checkScope: () => checkScope,
48
49
  createClient: () => createClient,
50
+ createJWKSResolver: () => createJWKSResolver,
49
51
  createToken: () => createToken,
50
52
  decodeHeader: () => decodeHeader,
51
53
  delegate: () => delegate,
52
54
  generateKeyPair: () => generateKeyPair2,
53
55
  getAuditTrail: () => getAuditTrail,
54
56
  getAuditTrailByService: () => getAuditTrailByService,
57
+ getDelegationAncestorIds: () => getDelegationAncestorIds,
55
58
  isBeforeNotBefore: () => isBeforeNotBefore,
56
59
  isExpired: () => isExpired,
57
60
  isRevoked: () => isRevoked,
@@ -59,6 +62,7 @@ __export(index_exports, {
59
62
  matchScope: () => matchScope,
60
63
  parseDefinition: () => parseDefinition,
61
64
  parseScope: () => parseScope,
65
+ publicKeyToJWK: () => publicKeyToJWK,
62
66
  revoke: () => revoke,
63
67
  sign: () => sign,
64
68
  signToken: () => signToken,
@@ -208,12 +212,14 @@ function parseScope(scope) {
208
212
  return scope.split(":");
209
213
  }
210
214
  function matchScope(pattern, requested) {
215
+ if (!pattern || !requested) return false;
211
216
  if (pattern === "*") return true;
212
217
  const patternParts = parseScope(pattern);
213
218
  const requestedParts = parseScope(requested);
214
219
  if (patternParts.length !== requestedParts.length) return false;
215
220
  for (let i = 0; i < patternParts.length; i++) {
216
221
  if (patternParts[i] === "*") continue;
222
+ if (!patternParts[i] || !requestedParts[i]) return false;
217
223
  if (patternParts[i] !== requestedParts[i]) return false;
218
224
  }
219
225
  return true;
@@ -379,8 +385,8 @@ async function authorize(token, service, action, options) {
379
385
  for (const rule of rules) {
380
386
  if (rule.enforcement === "hard") {
381
387
  const ruleKey = rule.id.startsWith("no-") ? rule.id.slice(3) : rule.id;
382
- const actionLower = action.toLowerCase();
383
- if (actionLower.includes(ruleKey.toLowerCase())) {
388
+ const actionSegments = action.toLowerCase().split(":");
389
+ if (actionSegments.includes(ruleKey.toLowerCase())) {
384
390
  return {
385
391
  authorized: false,
386
392
  reason: `hard rule '${rule.id}' violated`,
@@ -488,6 +494,13 @@ function validateDefinition(raw) {
488
494
  }
489
495
  if (!svc.scopes || !Array.isArray(svc.scopes) || svc.scopes.length === 0) {
490
496
  errors.push(`services[${i}].scopes must be a non-empty array`);
497
+ } else {
498
+ for (let j = 0; j < svc.scopes.length; j++) {
499
+ const s = svc.scopes[j];
500
+ if (typeof s !== "string" || s.length === 0) {
501
+ errors.push(`services[${i}].scopes[${j}] must be a non-empty string`);
502
+ }
503
+ }
491
504
  }
492
505
  validateServiceAccessMode(svc, i, errors, warnings);
493
506
  }
@@ -538,6 +551,11 @@ function validateMetadata(metadata, errors) {
538
551
  }
539
552
  const record = metadata;
540
553
  for (const key of keys) {
554
+ if (key.startsWith("_")) {
555
+ errors.push(
556
+ `metadata key '${key}' uses reserved prefix '_' (reserved for SDK internal use)`
557
+ );
558
+ }
541
559
  const value = record[key];
542
560
  if (value !== null && typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
543
561
  errors.push(
@@ -614,9 +632,7 @@ function validateLegalFramework(legal, errors) {
614
632
  }
615
633
 
616
634
  // src/revocation/revoke.ts
617
- var defaultStore = new MemoryRevocationStore();
618
635
  async function revoke(tokenId, options, store) {
619
- const s = store ?? defaultStore;
620
636
  const record = {
621
637
  tokenId,
622
638
  revokedAt: /* @__PURE__ */ new Date(),
@@ -624,19 +640,18 @@ async function revoke(tokenId, options, store) {
624
640
  reason: options.reason,
625
641
  cascaded: []
626
642
  };
627
- await s.add(record);
643
+ await store.add(record);
628
644
  return record;
629
645
  }
630
646
  async function isRevoked(tokenId, store) {
631
- const s = store ?? defaultStore;
632
- const record = await s.check(tokenId);
647
+ const record = await store.check(tokenId);
633
648
  return record !== null;
634
649
  }
635
650
 
636
651
  // src/audit/log.ts
637
- var defaultStore2 = new MemoryAuditStore();
652
+ var defaultStore = new MemoryAuditStore();
638
653
  async function logAction(tokenId, entry, store) {
639
- const s = store ?? defaultStore2;
654
+ const s = store ?? defaultStore;
640
655
  const fullEntry = {
641
656
  ...entry,
642
657
  tokenId,
@@ -646,13 +661,13 @@ async function logAction(tokenId, entry, store) {
646
661
  }
647
662
 
648
663
  // src/audit/trail.ts
649
- var defaultStore3 = new MemoryAuditStore();
664
+ var defaultStore2 = new MemoryAuditStore();
650
665
  async function getAuditTrail(tokenId, options, store) {
651
- const s = store ?? defaultStore3;
666
+ const s = store ?? defaultStore2;
652
667
  return s.query(tokenId, options);
653
668
  }
654
669
  async function getAuditTrailByService(service, options, store) {
655
- const s = store ?? defaultStore3;
670
+ const s = store ?? defaultStore2;
656
671
  return s.queryByService(service, options);
657
672
  }
658
673
 
@@ -682,7 +697,7 @@ async function verifySignature(token, key) {
682
697
  }
683
698
 
684
699
  // src/token/create.ts
685
- async function createToken(definition, options) {
700
+ async function createToken(definition, options, parentTokenId) {
686
701
  if (definition.metadata) {
687
702
  validateMetadata2(definition.metadata);
688
703
  }
@@ -698,7 +713,10 @@ async function createToken(definition, options) {
698
713
  exp: Math.floor(
699
714
  (typeof definition.expires === "string" ? new Date(definition.expires) : definition.expires).getTime() / 1e3
700
715
  ),
701
- definition: serializeDefinition(definition)
716
+ definition: {
717
+ ...serializeDefinition(definition),
718
+ ...parentTokenId ? { parentToken: parentTokenId } : {}
719
+ }
702
720
  };
703
721
  if (definition.notBefore) {
704
722
  payload.nbf = Math.floor(
@@ -719,6 +737,7 @@ async function createToken(definition, options) {
719
737
  signature: raw.split(".")[2],
720
738
  issuer,
721
739
  audience,
740
+ parentToken: parentTokenId,
722
741
  raw
723
742
  };
724
743
  return token;
@@ -806,13 +825,29 @@ async function validateToken(token, options) {
806
825
  "No key provided: supply publicKey, keyResolver, or publicKeyResolver"
807
826
  );
808
827
  }
828
+ const permittedAlgorithms = options.algorithms ?? ["EdDSA", "ES256"];
829
+ let headerAlg;
830
+ try {
831
+ const header = decodeHeader(rawJwt);
832
+ headerAlg = typeof header.alg === "string" ? header.alg : void 0;
833
+ } catch {
834
+ }
835
+ if (headerAlg && !permittedAlgorithms.includes(headerAlg)) {
836
+ errors.push(
837
+ `Token alg '${headerAlg}' is not in the permitted list [${permittedAlgorithms.join(", ")}]`
838
+ );
839
+ }
809
840
  let payload;
810
- if (publicKey) {
841
+ let signatureVerified = false;
842
+ if (publicKey && headerAlg && permittedAlgorithms.includes(headerAlg)) {
811
843
  try {
812
844
  payload = await verifySignature(rawJwt, publicKey);
845
+ signatureVerified = true;
813
846
  } catch {
814
847
  errors.push("Signature verification failed");
815
848
  }
849
+ } else if (publicKey && !headerAlg) {
850
+ errors.push("Token header missing alg");
816
851
  }
817
852
  if (!payload) {
818
853
  try {
@@ -828,10 +863,12 @@ async function validateToken(token, options) {
828
863
  if (!payload) {
829
864
  return { valid: false, errors: errors.length > 0 ? errors : ["Unable to decode token"], warnings };
830
865
  }
831
- try {
832
- parsedToken = payloadToToken(payload, rawJwt);
833
- } catch {
834
- errors.push("Token payload has invalid structure");
866
+ if (signatureVerified) {
867
+ try {
868
+ parsedToken = payloadToToken(payload, rawJwt);
869
+ } catch {
870
+ errors.push("Token payload has invalid structure");
871
+ }
835
872
  }
836
873
  const clockSkew = options.clockSkew;
837
874
  if (payload.exp !== void 0) {
@@ -895,9 +932,7 @@ function base64urlDecode(input) {
895
932
  }
896
933
 
897
934
  // src/revocation/cascade.ts
898
- var defaultStore4 = new MemoryRevocationStore();
899
935
  async function cascadeRevoke(parentTokenId, childTokenIds, options, store) {
900
- const s = store ?? defaultStore4;
901
936
  const revokedAt = /* @__PURE__ */ new Date();
902
937
  for (const childId of childTokenIds) {
903
938
  const childRecord = {
@@ -907,7 +942,7 @@ async function cascadeRevoke(parentTokenId, childTokenIds, options, store) {
907
942
  reason: options.reason ? `Cascade: ${options.reason}` : `Cascade revocation from parent ${parentTokenId}`,
908
943
  cascaded: []
909
944
  };
910
- await s.add(childRecord);
945
+ await store.add(childRecord);
911
946
  }
912
947
  const parentRecord = {
913
948
  tokenId: parentTokenId,
@@ -916,7 +951,7 @@ async function cascadeRevoke(parentTokenId, childTokenIds, options, store) {
916
951
  reason: options.reason,
917
952
  cascaded: childTokenIds
918
953
  };
919
- await s.add(parentRecord);
954
+ await store.add(parentRecord);
920
955
  return parentRecord;
921
956
  }
922
957
 
@@ -965,7 +1000,12 @@ function verifyAttenuation(parent, child, currentDepth = 0) {
965
1000
  verifyConstraintsNotRelaxed(parentService, childService);
966
1001
  }
967
1002
  if (parentDef.rules && parentDef.rules.length > 0) {
968
- verifyRulesNotRemoved(parentDef.rules.map((r) => r.id), child);
1003
+ verifyRulesNotRemoved(
1004
+ parentDef.rules.map((r) => r.id),
1005
+ child,
1006
+ parentDef.services.flatMap((s) => s.scopes),
1007
+ child.services.flatMap((s) => s.scopes)
1008
+ );
969
1009
  }
970
1010
  }
971
1011
  function verifyScopeSubset(parent, child) {
@@ -987,26 +1027,23 @@ function verifyConstraintsNotRelaxed(parent, child) {
987
1027
  for (const [key, parentValue] of Object.entries(parent.constraints)) {
988
1028
  if (parentValue === false) {
989
1029
  const childValue = child.constraints?.[key];
990
- if (childValue === true || childValue === void 0) {
991
- if (childValue === true) {
992
- throw new AttenuationViolationError(
993
- `Child relaxes constraint '${key}' on service '${child.service}' (parent: false, child: true)`,
994
- parent.scopes,
995
- child.scopes
996
- );
997
- }
998
- }
1030
+ if (childValue === false) continue;
1031
+ throw new AttenuationViolationError(
1032
+ childValue === true ? `Child relaxes constraint '${key}' on service '${child.service}' (parent: false, child: true)` : `Child omits constraint '${key}' on service '${child.service}' (parent: false, child: undefined)`,
1033
+ parent.scopes,
1034
+ child.scopes
1035
+ );
999
1036
  }
1000
1037
  }
1001
1038
  }
1002
- function verifyRulesNotRemoved(parentRuleIds, child) {
1039
+ function verifyRulesNotRemoved(parentRuleIds, child, parentScopes, childScopes) {
1003
1040
  const childRuleIds = new Set(child.rules?.map((r) => r.id) ?? []);
1004
1041
  for (const parentRuleId of parentRuleIds) {
1005
1042
  if (!childRuleIds.has(parentRuleId)) {
1006
1043
  throw new AttenuationViolationError(
1007
1044
  `Child removes parent rule '${parentRuleId}'. Rules can only be added, not removed.`,
1008
- [],
1009
- []
1045
+ parentScopes,
1046
+ childScopes
1010
1047
  );
1011
1048
  }
1012
1049
  }
@@ -1015,7 +1052,6 @@ function verifyRulesNotRemoved(parentRuleIds, child) {
1015
1052
  // src/delegation/chain.ts
1016
1053
  async function delegate(parentToken, childDef, options) {
1017
1054
  const currentDepth = countDepth(parentToken);
1018
- verifyAttenuation(parentToken, childDef, currentDepth);
1019
1055
  const parentDef = parentToken.definition;
1020
1056
  const parentRules = parentDef.rules ?? [];
1021
1057
  const childExtraRules = (childDef.rules ?? []).filter(
@@ -1027,12 +1063,32 @@ async function delegate(parentToken, childDef, options) {
1027
1063
  ...childDef.metadata,
1028
1064
  _delegationDepth: childDepth
1029
1065
  };
1066
+ const inheritedServices = childDef.services.map((childSvc) => {
1067
+ const parentSvc = parentDef.services.find((s) => s.service === childSvc.service);
1068
+ if (!parentSvc?.constraints) return childSvc;
1069
+ const inherited = {};
1070
+ for (const [key, value] of Object.entries(parentSvc.constraints)) {
1071
+ if (value === false) {
1072
+ inherited[key] = false;
1073
+ }
1074
+ }
1075
+ if (Object.keys(inherited).length === 0) return childSvc;
1076
+ return {
1077
+ ...childSvc,
1078
+ constraints: { ...inherited, ...childSvc.constraints }
1079
+ };
1080
+ });
1081
+ verifyAttenuation(
1082
+ parentToken,
1083
+ { ...childDef, services: inheritedServices, rules: mergedRules },
1084
+ currentDepth
1085
+ );
1030
1086
  const fullDefinition = {
1031
1087
  principal: parentDef.principal,
1032
1088
  // Inherited — cannot be overridden
1033
1089
  agent: childDef.agent,
1034
1090
  agentProvider: parentDef.agentProvider,
1035
- services: childDef.services,
1091
+ services: inheritedServices,
1036
1092
  rules: mergedRules.length > 0 ? mergedRules : void 0,
1037
1093
  expires: childDef.expires ?? parentDef.expires,
1038
1094
  revocable: parentDef.revocable,
@@ -1041,8 +1097,7 @@ async function delegate(parentToken, childDef, options) {
1041
1097
  metadata: childMetadata,
1042
1098
  legal: parentDef.legal
1043
1099
  };
1044
- const childToken = await createToken(fullDefinition, options);
1045
- childToken.parentToken = parentToken.jti;
1100
+ const childToken = await createToken(fullDefinition, options, parentToken.jti);
1046
1101
  return childToken;
1047
1102
  }
1048
1103
  function countDepth(token) {
@@ -1136,6 +1191,18 @@ function checkAttenuation(parent, child, index, errors) {
1136
1191
  );
1137
1192
  }
1138
1193
  }
1194
+ if (parentService.constraints) {
1195
+ for (const [key, parentValue] of Object.entries(parentService.constraints)) {
1196
+ if (parentValue === false) {
1197
+ const childValue = childService.constraints?.[key];
1198
+ if (childValue !== false) {
1199
+ errors.push(
1200
+ `Chain link ${index}: constraint '${key}' on '${childService.service}' relaxed by child (parent: false, child: ${childValue === void 0 ? "undefined" : JSON.stringify(childValue)})`
1201
+ );
1202
+ }
1203
+ }
1204
+ }
1205
+ }
1139
1206
  }
1140
1207
  const childExp = child.definition.expires instanceof Date ? child.definition.expires.getTime() : new Date(child.definition.expires).getTime();
1141
1208
  const parentExp = parent.definition.expires instanceof Date ? parent.definition.expires.getTime() : new Date(parent.definition.expires).getTime();
@@ -1146,6 +1213,113 @@ function checkAttenuation(parent, child, index, errors) {
1146
1213
  }
1147
1214
  }
1148
1215
 
1216
+ // src/delegation/ancestors.ts
1217
+ function getDelegationAncestorIds(input) {
1218
+ const ids = [];
1219
+ const seen = /* @__PURE__ */ new Set();
1220
+ const push = (value) => {
1221
+ if (typeof value !== "string" || value.length === 0 || seen.has(value)) {
1222
+ return;
1223
+ }
1224
+ seen.add(value);
1225
+ ids.push(value);
1226
+ };
1227
+ const token = input;
1228
+ const definition = hasDefinition(input) ? token.definition : input;
1229
+ push(token.parentToken);
1230
+ push(definition?.parentToken);
1231
+ const chain = definition?.delegationChain;
1232
+ if (Array.isArray(chain)) {
1233
+ for (const link of chain) {
1234
+ if (typeof link === "string") {
1235
+ push(link);
1236
+ } else if (link && typeof link === "object") {
1237
+ push(link.parentTokenId);
1238
+ }
1239
+ }
1240
+ }
1241
+ return ids;
1242
+ }
1243
+ function hasDefinition(input) {
1244
+ return Boolean(input && typeof input === "object" && "definition" in input);
1245
+ }
1246
+
1247
+ // src/jwks/index.ts
1248
+ var jose3 = __toESM(require("jose"), 1);
1249
+ async function publicKeyToJWK(publicKey, options) {
1250
+ const exported = await jose3.exportJWK(publicKey);
1251
+ const alg = options.alg ?? defaultAlgorithm(exported);
1252
+ return {
1253
+ ...exported,
1254
+ kid: options.kid,
1255
+ use: options.use ?? "sig",
1256
+ alg
1257
+ };
1258
+ }
1259
+ function buildJWKS(keys) {
1260
+ return { keys };
1261
+ }
1262
+ function createJWKSResolver(url, options = {}) {
1263
+ if (!options.fetch && !options.allowInsecure && !url.startsWith("https://")) {
1264
+ throw new Error(
1265
+ `JWKS URL must use https:// (got: ${JSON.stringify(url)}). Pass allowInsecure: true or a custom fetch for local development.`
1266
+ );
1267
+ }
1268
+ const cacheMaxAgeMs = options.cacheMaxAgeMs ?? 60 * 60 * 1e3;
1269
+ const cooldownMs = options.cooldownMs ?? 24 * 60 * 60 * 1e3;
1270
+ const fetchImpl = options.fetch ?? fetch;
1271
+ let cache = null;
1272
+ let inflight = null;
1273
+ async function fetchJWKS() {
1274
+ const response = await fetchImpl(url, {
1275
+ headers: { accept: "application/jwk-set+json, application/json" }
1276
+ });
1277
+ if (!response.ok) {
1278
+ throw new Error(`JWKS fetch failed: ${response.status} ${response.statusText}`);
1279
+ }
1280
+ const body = await response.json();
1281
+ if (!body || !Array.isArray(body.keys)) {
1282
+ throw new Error("JWKS response did not contain a `keys` array");
1283
+ }
1284
+ return body;
1285
+ }
1286
+ async function getJWKS() {
1287
+ const now = Date.now();
1288
+ if (cache && now - cache.fetchedAt < cacheMaxAgeMs) {
1289
+ return cache.jwks;
1290
+ }
1291
+ if (inflight) {
1292
+ return inflight;
1293
+ }
1294
+ inflight = fetchJWKS().then((jwks) => {
1295
+ cache = { fetchedAt: Date.now(), jwks };
1296
+ return jwks;
1297
+ }).catch((err) => {
1298
+ if (cache && now - cache.fetchedAt < cooldownMs) {
1299
+ return cache.jwks;
1300
+ }
1301
+ throw err;
1302
+ }).finally(() => {
1303
+ inflight = null;
1304
+ });
1305
+ return inflight;
1306
+ }
1307
+ return {
1308
+ async resolve(kid) {
1309
+ const jwks = await getJWKS();
1310
+ const match = jwks.keys.find((k) => k.kid === kid);
1311
+ if (!match) return null;
1312
+ const key = await jose3.importJWK(match, match.alg);
1313
+ return key;
1314
+ }
1315
+ };
1316
+ }
1317
+ function defaultAlgorithm(jwk) {
1318
+ if (jwk.kty === "OKP" && jwk.crv === "Ed25519") return "EdDSA";
1319
+ if (jwk.kty === "EC" && jwk.crv === "P-256") return "ES256";
1320
+ return jwk.alg ?? "EdDSA";
1321
+ }
1322
+
1149
1323
  // src/client.ts
1150
1324
  function createClient(options) {
1151
1325
  const revocationStore = options?.revocationStore ?? new MemoryRevocationStore();
@@ -1229,16 +1403,19 @@ function createClient(options) {
1229
1403
  ScopeViolationError,
1230
1404
  TokenExpiredError,
1231
1405
  authorize,
1406
+ buildJWKS,
1232
1407
  cascadeRevoke,
1233
1408
  checkConstraint,
1234
1409
  checkScope,
1235
1410
  createClient,
1411
+ createJWKSResolver,
1236
1412
  createToken,
1237
1413
  decodeHeader,
1238
1414
  delegate,
1239
1415
  generateKeyPair,
1240
1416
  getAuditTrail,
1241
1417
  getAuditTrailByService,
1418
+ getDelegationAncestorIds,
1242
1419
  isBeforeNotBefore,
1243
1420
  isExpired,
1244
1421
  isRevoked,
@@ -1246,6 +1423,7 @@ function createClient(options) {
1246
1423
  matchScope,
1247
1424
  parseDefinition,
1248
1425
  parseScope,
1426
+ publicKeyToJWK,
1249
1427
  revoke,
1250
1428
  sign,
1251
1429
  signToken,