@webpieces/http-routing 0.4.863 → 0.4.864

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +3 -1
  2. package/package.json +4 -4
  3. package/src/ApiClientFactory.js +1 -1
  4. package/src/ApiClientFactory.js.map +1 -1
  5. package/src/ApiFactory.d.ts +3 -0
  6. package/src/ApiFactory.js.map +1 -1
  7. package/src/ApiRoutingFactory.js +3 -3
  8. package/src/ApiRoutingFactory.js.map +1 -1
  9. package/src/AuthConfig.d.ts +15 -8
  10. package/src/AuthConfig.js +20 -8
  11. package/src/AuthConfig.js.map +1 -1
  12. package/src/AuthHooks.d.ts +13 -61
  13. package/src/AuthHooks.js +7 -44
  14. package/src/AuthHooks.js.map +1 -1
  15. package/src/AuthenticatedCallerContext.d.ts +6 -0
  16. package/src/AuthenticatedCallerContext.js +44 -0
  17. package/src/AuthenticatedCallerContext.js.map +1 -1
  18. package/src/AuthorizationHook.d.ts +34 -0
  19. package/src/AuthorizationHook.js +121 -0
  20. package/src/AuthorizationHook.js.map +1 -0
  21. package/src/AuthorizedApiDocument.d.ts +18 -0
  22. package/src/AuthorizedApiDocument.js +137 -0
  23. package/src/AuthorizedApiDocument.js.map +1 -0
  24. package/src/DefaultJwtHook.d.ts +4 -4
  25. package/src/DefaultJwtHook.js +10 -7
  26. package/src/DefaultJwtHook.js.map +1 -1
  27. package/src/DefaultOidcVerifier.d.ts +4 -3
  28. package/src/DefaultOidcVerifier.js +6 -4
  29. package/src/DefaultOidcVerifier.js.map +1 -1
  30. package/src/InvocationAuthentication.d.ts +7 -7
  31. package/src/InvocationAuthentication.js +18 -17
  32. package/src/InvocationAuthentication.js.map +1 -1
  33. package/src/JwtHookCompileAssertions.d.ts +1 -1
  34. package/src/JwtHookCompileAssertions.js +2 -5
  35. package/src/JwtHookCompileAssertions.js.map +1 -1
  36. package/src/RouteBuilderImpl.js +7 -0
  37. package/src/RouteBuilderImpl.js.map +1 -1
  38. package/src/WebpiecesRouter.d.ts +2 -0
  39. package/src/WebpiecesRouter.js +6 -0
  40. package/src/WebpiecesRouter.js.map +1 -1
  41. package/src/filters/AuthFilter.d.ts +11 -22
  42. package/src/filters/AuthFilter.js +75 -80
  43. package/src/filters/AuthFilter.js.map +1 -1
  44. package/src/filters/AuthorizationFilter.d.ts +10 -0
  45. package/src/filters/AuthorizationFilter.js +34 -0
  46. package/src/filters/AuthorizationFilter.js.map +1 -0
  47. package/src/index.d.ts +5 -2
  48. package/src/index.js +25 -10
  49. package/src/index.js.map +1 -1
  50. package/src/setupRuntime.d.ts +2 -2
  51. package/src/setupRuntime.js +2 -2
  52. package/src/setupRuntime.js.map +1 -1
package/src/AuthHooks.js CHANGED
@@ -1,7 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.API_KEY_HOOK = exports.ApiKeyHook = exports.WEBHOOK_AUTH_CALLBACK = exports.WebhookAuthCallback = exports.OIDC_HOOK = exports.OidcHook = exports.JWT_HOOK = exports.JwtHook = exports.MintedJwt = void 0;
4
- const core_util_1 = require("@webpieces/core-util");
5
4
  /** Framework-normalized result from an application-owned JWT mint operation. */
6
5
  class MintedJwt {
7
6
  token;
@@ -17,44 +16,8 @@ class MintedJwt {
17
16
  }
18
17
  }
19
18
  exports.MintedJwt = MintedJwt;
20
- /**
21
- * JwtHook - the OPTIONAL user-JWT mechanism. Its DI token is the {@link JWT_HOOK} Symbol injected via
22
- * `@inject(JWT_HOOK)` (a Symbol, because the app container uses autobind; rebindable in tests). Bind one
23
- * to turn on `@WpAuthJwt({...})` endpoints. When NO JwtHook is bound, the framework
24
- * {@link AuthFilter} treats every jwt endpoint as "not enabled" and fails fast (401) — there is no
25
- * default JWT verification because it needs an app secret + payload shape the framework can't guess.
26
- *
27
- * - `mint` — ISSUANCE: construct/sign a JWT from an application-owned request shape.
28
- * - `parseJwt` — AUTHENTICATION: decode/verify a user JWT into {@link AuthenticatedCaller}, or throw. The
29
- * app owns the strategy (HS256 secret, RS256 + JWKS, a provider SDK, ...).
30
- * - `authorizeJwt` — AUTHORIZATION: check the authenticated user against the endpoint's
31
- * {@link JwtRequirement}. The DEFAULT enforces the roles any-of; override for
32
- * app-defined requirements carried by the SAME decorator, e.g.
33
- * `@WpAuthJwt({allRolesAllowed: true, inOrg: true})` →
34
- * `if (requirement['inOrg'] && !values.claims['orgId']) ...`.
35
- *
36
- * ALL THREE ARE ASYNC for the same reason: the strategy is the app's, and an app's strategy reaches
37
- * the network. `mint` may call KMS/HSM, `parseJwt` may fetch a JWKS or call a provider SDK, and `authorizeJwt`'s
38
- * motivating example — `@WpAuthJwt({allRolesAllowed: true, inOrg: true})` — is a membership question a
39
- * real app answers from a datastore. A sync signature makes both of those unwritable, and it made
40
- * `JwtHook` the last sync hook: {@link OidcHook.verifyOidc}, {@link WebhookAuthCallback.verifyWebhook} and
41
- * {@link ApiKeyHook.verifyApiKey} all return promises. An implementation that needs no I/O simply has
42
- * no `await` in its body — {@link DefaultJwtHook} is exactly that and pays nothing for it.
43
- */
19
+ /** Optional application JWT credential authority. Authorization lives in AuthorizationHook. */
44
20
  class JwtHook {
45
- /**
46
- * DEFAULT authorization: enforce the endpoint's roles (any-of). Override to enforce app-defined
47
- * requirements. Throw ApiForbiddenError to deny; return to allow. ASYNC so an app-defined
48
- * requirement can be answered from a datastore; see the class doc.
49
- */
50
- async authorizeJwt(caller, requirement) {
51
- // rolesRequired is the ONE reader of the JwtRoles union: [] means the endpoint typed
52
- // `allRolesAllowed: true`, never "the field was missing" — that state no longer compiles.
53
- const roles = (0, core_util_1.rolesRequired)(requirement);
54
- if (roles.length > 0 && !roles.some((role) => caller.roles.includes(role))) {
55
- throw new core_util_1.ApiForbiddenError(`Endpoint requires one of roles: ${roles.join(', ')}`);
56
- }
57
- }
58
21
  }
59
22
  exports.JwtHook = JwtHook;
60
23
  /**
@@ -70,7 +33,7 @@ exports.JWT_HOOK = Symbol.for('JwtHook');
70
33
  * autobind; rebindable in tests). Bind one ONLY to customize the caller policy — e.g. an app that reads an
71
34
  * `ALLOWED_OIDC_CALLERS` env var at its composition root and enforces that allow-list. When NO
72
35
  * OidcHook is bound, the framework {@link AuthFilter} runs the built-in {@link DefaultOidcVerifier}
73
- * directly, so a server that wires nothing still verifies Google OIDC against its `@WpAuthOidc(...callers)`
36
+ * directly, so a server that wires nothing still verifies Google OIDC against its `oidc(...callers)`
74
37
  * (else trusts the edge — any Google-signed caller). `verifyOidc` verifies the token against `callers`;
75
38
  * throw on failure.
76
39
  */
@@ -85,7 +48,7 @@ exports.OidcHook = OidcHook;
85
48
  // webpieces-disable no-symbol-di-tokens -- optional DI token: must be a Symbol so the app container's autobind never auto-constructs this token, keeping @optional() @inject(...) correct (undefined when unbound)
86
49
  exports.OIDC_HOOK = Symbol.for('OidcHook');
87
50
  /**
88
- * WebhookAuthCallback - the OPTIONAL mechanism behind `@WpAuthWebhook(name)`: prove that an inbound request was
51
+ * WebhookAuthCallback - the OPTIONAL mechanism behind `webhook(name)`: prove that an inbound request was
89
52
  * really authored by the outside vendor the contract names. Its DI token is the {@link WEBHOOK_AUTH_CALLBACK}
90
53
  * Symbol injected via `@inject(WEBHOOK_AUTH_CALLBACK)` (a Symbol, because the app container uses autobind;
91
54
  * rebindable in tests). The third hook, symmetric with {@link JwtHook} / {@link OidcHook}:
@@ -95,10 +58,10 @@ exports.OIDC_HOOK = Symbol.for('OidcHook');
95
58
  * options.bind(WEBHOOK_AUTH_CALLBACK).to(CompanyWebhookAuthCallback);
96
59
  * ```
97
60
  *
98
- * When NO WebhookAuthCallback is bound, the framework {@link AuthFilter} 401s every `@WpAuthWebhook` endpoint,
61
+ * When NO WebhookAuthCallback is bound, the framework {@link AuthFilter} 401s every `webhook(...)` endpoint,
99
62
  * exactly as it does for an unbound JwtHook. There is no framework default and there never will be
100
63
  * one: silently allowing an unverified webhook is the single default that must not exist, and the
101
- * framework ships no vendor crypto by design (see {@link WpAuthWebhook} for why reimplementing five
64
+ * framework ships no vendor crypto by design (see {@link webhook} for why reimplementing five
102
65
  * vendors' schemes is a losing trade).
103
66
  *
104
67
  * ONE hook serves EVERY vendor: `name` selects which, so an app with a Sentry hook and a Twilio hook
@@ -117,7 +80,7 @@ exports.WebhookAuthCallback = WebhookAuthCallback;
117
80
  // webpieces-disable no-symbol-di-tokens -- optional DI token: must be a Symbol so the app container's autobind never auto-constructs this token, keeping @optional() @inject(...) correct (undefined when unbound)
118
81
  exports.WEBHOOK_AUTH_CALLBACK = Symbol.for('WebhookAuthCallback');
119
82
  /**
120
- * ApiKeyHook - the OPTIONAL mechanism behind `@WpAuthApiKey(regime, credentials)`: authenticate a
83
+ * ApiKeyHook - the OPTIONAL mechanism behind `apiKey(regime, credentials)`: authenticate a
121
84
  * CUSTOMER-held api key
122
85
  * against the app's own datastore and return the context to seed. Its DI token is the
123
86
  * {@link API_KEY_HOOK} Symbol injected via `@inject(API_KEY_HOOK)` (a Symbol, because the app container
@@ -129,7 +92,7 @@ exports.WEBHOOK_AUTH_CALLBACK = Symbol.for('WebhookAuthCallback');
129
92
  * options.bind(API_KEY_HOOK).to(OneTabletApiKeyHook);
130
93
  * ```
131
94
  *
132
- * When NO ApiKeyHook is bound, the framework {@link AuthFilter} 401s every `@WpAuthApiKey` endpoint,
95
+ * When NO ApiKeyHook is bound, the framework {@link AuthFilter} 401s every `apiKey(...)` endpoint,
133
96
  * exactly as it does for an unbound JwtHook. There is no framework default and there never will be
134
97
  * one: the key regime lives in the app's datastore, under the app's hashing scheme, behind the app's
135
98
  * choice of header names.
@@ -1 +1 @@
1
- {"version":3,"file":"AuthHooks.js","sourceRoot":"","sources":["../../../../../packages/http/http-routing/src/AuthHooks.ts"],"names":[],"mappings":";;;AAAA,oDAAwF;AAIxF,gFAAgF;AAChF,MAAa,SAAS;IAEE;IACA;IAFpB,YACoB,KAAa,EACb,qBAA6B;QAD7B,UAAK,GAAL,KAAK,CAAQ;QACb,0BAAqB,GAArB,qBAAqB,CAAQ;QAE7C,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QAChF,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,KAAK,CAAC,wDAAwD,CAAC,CAAC;QAC9E,CAAC;IACL,CAAC;CACJ;AAVD,8BAUC;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAsB,OAAO;IAuBzB;;;;OAIG;IACH,KAAK,CAAC,YAAY,CAAC,MAA2B,EAAE,WAA2B;QACvE,qFAAqF;QACrF,0FAA0F;QAC1F,MAAM,KAAK,GAAG,IAAA,yBAAa,EAAC,WAAW,CAAC,CAAC;QACzC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAY,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC;YACjF,MAAM,IAAI,6BAAiB,CAAC,mCAAmC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACvF,CAAC;IACL,CAAC;CACJ;AApCD,0BAoCC;AAED;;;;GAIG;AACH,mNAAmN;AACtM,QAAA,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;AAE9C;;;;;;;;;GASG;AACH,MAAsB,QAAQ;CAE7B;AAFD,4BAEC;AAED;;;;GAIG;AACH,mNAAmN;AACtM,QAAA,SAAS,GAAG,MAAM,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;AAEhD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAsB,mBAAmB;CAuBxC;AAvBD,kDAuBC;AAED;;;;GAIG;AACH,mNAAmN;AACtM,QAAA,qBAAqB,GAAG,MAAM,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC;AAEvE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,MAAsB,UAAU;CAiB/B;AAjBD,gCAiBC;AAED;;;;GAIG;AACH,mNAAmN;AACtM,QAAA,YAAY,GAAG,MAAM,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC","sourcesContent":["import { JwtRequirement, rolesRequired, ApiForbiddenError } from '@webpieces/core-util';\nimport { HttpRequest, RawHttpRequest } from '@webpieces/core-context';\nimport { AuthenticatedCaller } from './AuthConfig';\n\n/** Framework-normalized result from an application-owned JWT mint operation. */\nexport class MintedJwt {\n constructor(\n public readonly token: string,\n public readonly expiresAtEpochSeconds: number,\n ) {\n if (token.trim() === '') throw new Error('Minted JWT token must be non-empty.');\n if (!Number.isFinite(expiresAtEpochSeconds)) {\n throw new Error('Minted JWT expiry must be a finite epoch-second value.');\n }\n }\n}\n\n/**\n * JwtHook - the OPTIONAL user-JWT mechanism. Its DI token is the {@link JWT_HOOK} Symbol injected via\n * `@inject(JWT_HOOK)` (a Symbol, because the app container uses autobind; rebindable in tests). Bind one\n * to turn on `@WpAuthJwt({...})` endpoints. When NO JwtHook is bound, the framework\n * {@link AuthFilter} treats every jwt endpoint as \"not enabled\" and fails fast (401) — there is no\n * default JWT verification because it needs an app secret + payload shape the framework can't guess.\n *\n * - `mint` — ISSUANCE: construct/sign a JWT from an application-owned request shape.\n * - `parseJwt` — AUTHENTICATION: decode/verify a user JWT into {@link AuthenticatedCaller}, or throw. The\n * app owns the strategy (HS256 secret, RS256 + JWKS, a provider SDK, ...).\n * - `authorizeJwt` — AUTHORIZATION: check the authenticated user against the endpoint's\n * {@link JwtRequirement}. The DEFAULT enforces the roles any-of; override for\n * app-defined requirements carried by the SAME decorator, e.g.\n * `@WpAuthJwt({allRolesAllowed: true, inOrg: true})` →\n * `if (requirement['inOrg'] && !values.claims['orgId']) ...`.\n *\n * ALL THREE ARE ASYNC for the same reason: the strategy is the app's, and an app's strategy reaches\n * the network. `mint` may call KMS/HSM, `parseJwt` may fetch a JWKS or call a provider SDK, and `authorizeJwt`'s\n * motivating example — `@WpAuthJwt({allRolesAllowed: true, inOrg: true})` — is a membership question a\n * real app answers from a datastore. A sync signature makes both of those unwritable, and it made\n * `JwtHook` the last sync hook: {@link OidcHook.verifyOidc}, {@link WebhookAuthCallback.verifyWebhook} and\n * {@link ApiKeyHook.verifyApiKey} all return promises. An implementation that needs no I/O simply has\n * no `await` in its body — {@link DefaultJwtHook} is exactly that and pays nothing for it.\n */\nexport abstract class JwtHook<TMintRequest> {\n /**\n * Mint an application endpoint JWT. The application owns the request/claim shape while the\n * framework constrains the returned token and expiry metadata. Always async so the authority\n * may use a remote signer, KMS, or HSM.\n */\n abstract mint(request: TMintRequest): Promise<MintedJwt>;\n\n /**\n * Parse a user JWT (kind:'jwt') — AUTHENTICATION only. Return who the user is, or throw.\n * ASYNC so an app can reach a JWKS endpoint or a provider SDK; see the class doc.\n *\n * IT TAKES THE TOKEN, NOT THE REQUEST — the one deliberate asymmetry among the four hooks, and\n * NOT an oversight to be \"fixed\". {@link ApiKeyHook.verifyApiKey} and\n * {@link WebhookAuthCallback.verifyWebhook} take the whole {@link HttpRequest} because their\n * credential regime is the APP's: which headers carry an api key, and how a vendor signs, are\n * things the framework cannot know. A user JWT is different — the framework owns the\n * `Authorization: Bearer` scheme and has already extracted the token from it. Widening this to\n * the request would only invite a JwtHook to authenticate off some OTHER header, which is a\n * second, ungoverned credential path on the mode that guards browser traffic.\n */\n abstract parseJwt(token: string): Promise<AuthenticatedCaller>;\n\n /**\n * DEFAULT authorization: enforce the endpoint's roles (any-of). Override to enforce app-defined\n * requirements. Throw ApiForbiddenError to deny; return to allow. ASYNC so an app-defined\n * requirement can be answered from a datastore; see the class doc.\n */\n async authorizeJwt(caller: AuthenticatedCaller, requirement: JwtRequirement): Promise<void> {\n // rolesRequired is the ONE reader of the JwtRoles union: [] means the endpoint typed\n // `allRolesAllowed: true`, never \"the field was missing\" — that state no longer compiles.\n const roles = rolesRequired(requirement);\n if (roles.length > 0 && !roles.some((role: string) => caller.roles.includes(role))) {\n throw new ApiForbiddenError(`Endpoint requires one of roles: ${roles.join(', ')}`);\n }\n }\n}\n\n/**\n * DI identifier for the optional {@link JwtHook} binding. It is a Symbol (not the class) so the app\n * container's inversify autobind never auto-constructs this token, keeping `@optional() @inject(JWT_HOOK)`\n * correct — undefined when unbound. The JwtHook class stays the TYPE and the impl base.\n */\n// webpieces-disable no-symbol-di-tokens -- optional DI token: must be a Symbol so the app container's autobind never auto-constructs this token, keeping @optional() @inject(...) correct (undefined when unbound)\nexport const JWT_HOOK = Symbol.for('JwtHook');\n\n/**\n * OidcHook - the OPTIONAL override for Google OIDC service-to-service verification. Its DI token is the\n * {@link OIDC_HOOK} Symbol injected via `@inject(OIDC_HOOK)` (a Symbol, because the app container uses\n * autobind; rebindable in tests). Bind one ONLY to customize the caller policy — e.g. an app that reads an\n * `ALLOWED_OIDC_CALLERS` env var at its composition root and enforces that allow-list. When NO\n * OidcHook is bound, the framework {@link AuthFilter} runs the built-in {@link DefaultOidcVerifier}\n * directly, so a server that wires nothing still verifies Google OIDC against its `@WpAuthOidc(...callers)`\n * (else trusts the edge — any Google-signed caller). `verifyOidc` verifies the token against `callers`;\n * throw on failure.\n */\nexport abstract class OidcHook {\n abstract verifyOidc(token: string, callers: string[]): Promise<void>;\n}\n\n/**\n * DI identifier for the optional {@link OidcHook} binding. It is a Symbol (not the class) so the app\n * container's inversify autobind never auto-constructs this token, keeping `@optional() @inject(OIDC_HOOK)`\n * correct — undefined when unbound. The OidcHook class stays the TYPE and the impl base.\n */\n// webpieces-disable no-symbol-di-tokens -- optional DI token: must be a Symbol so the app container's autobind never auto-constructs this token, keeping @optional() @inject(...) correct (undefined when unbound)\nexport const OIDC_HOOK = Symbol.for('OidcHook');\n\n/**\n * WebhookAuthCallback - the OPTIONAL mechanism behind `@WpAuthWebhook(name)`: prove that an inbound request was\n * really authored by the outside vendor the contract names. Its DI token is the {@link WEBHOOK_AUTH_CALLBACK}\n * Symbol injected via `@inject(WEBHOOK_AUTH_CALLBACK)` (a Symbol, because the app container uses autobind;\n * rebindable in tests). The third hook, symmetric with {@link JwtHook} / {@link OidcHook}:\n *\n * ```typescript\n * // AppModule.ts, beside the CompanyJwtHook binding\n * options.bind(WEBHOOK_AUTH_CALLBACK).to(CompanyWebhookAuthCallback);\n * ```\n *\n * When NO WebhookAuthCallback is bound, the framework {@link AuthFilter} 401s every `@WpAuthWebhook` endpoint,\n * exactly as it does for an unbound JwtHook. There is no framework default and there never will be\n * one: silently allowing an unverified webhook is the single default that must not exist, and the\n * framework ships no vendor crypto by design (see {@link WpAuthWebhook} for why reimplementing five\n * vendors' schemes is a losing trade).\n *\n * ONE hook serves EVERY vendor: `name` selects which, so an app with a Sentry hook and a Twilio hook\n * switches on it rather than binding a token per vendor. What arrives is enough of the raw request to\n * call the vendor's OWN validator — `request.raw.rawBody` for a body-signing vendor (Sentry, GitHub,\n * Stripe, Slack), `request.raw.absoluteUrl` for one that signs the url instead (Twilio).\n */\nexport abstract class WebhookAuthCallback {\n /**\n * Verify ONE inbound request. Return the {@link AuthenticatedCaller} the vendor's signature\n * proved; throw {@link ApiUnauthorizedError} to deny.\n *\n * IT RETURNS A CALLER, not `void`, for the same reason {@link ApiKeyHook.verifyApiKey} does: once\n * the signature checks out, the payload's vendor account is a PROVEN fact, and a hook that could\n * only return `void` had no way to say so. The framework seeds `entries` with\n * `RequestContext.putTrusted` exactly as it does for a jwt or api-key caller, so a controller\n * reads which vendor account this webhook is for off the context instead of re-deriving it.\n *\n * Return only what THIS hook proved from the signature it just verified. `webhook` remains\n * caller-NOT-verified (see `AuthFilter.verifiesCaller`): a vendor is not a peer service, so\n * nothing the vendor merely ASSERTED on the wire is admitted.\n *\n * @param name the string on the contract's `@WpAuthWebhook(name)` — which vendor this route is.\n * @param request the transport-neutral request, narrowed to {@link RawHttpRequest}: `request.raw`\n * holds the verbatim bytes + absolute url and is PRESENT, never optional.\n * `@WpAuthWebhook` requires `@Endpoint(..., { rawBody: true })` at wiring time, and\n * AuthFilter 401s rather than calling this hook with nothing to check — so an\n * implementation never writes `raw!` or a guard of its own.\n */\n abstract verifyWebhook(name: string, request: RawHttpRequest): Promise<AuthenticatedCaller>;\n}\n\n/**\n * DI identifier for the optional {@link WebhookAuthCallback} binding. It is a Symbol (not the class) so the app\n * container's inversify autobind never auto-constructs this token, keeping `@optional() @inject(WEBHOOK_AUTH_CALLBACK)`\n * correct — undefined when unbound. The WebhookAuthCallback class stays the TYPE and the impl base.\n */\n// webpieces-disable no-symbol-di-tokens -- optional DI token: must be a Symbol so the app container's autobind never auto-constructs this token, keeping @optional() @inject(...) correct (undefined when unbound)\nexport const WEBHOOK_AUTH_CALLBACK = Symbol.for('WebhookAuthCallback');\n\n/**\n * ApiKeyHook - the OPTIONAL mechanism behind `@WpAuthApiKey(regime, credentials)`: authenticate a\n * CUSTOMER-held api key\n * against the app's own datastore and return the context to seed. Its DI token is the\n * {@link API_KEY_HOOK} Symbol injected via `@inject(API_KEY_HOOK)` (a Symbol, because the app container\n * uses autobind; rebindable in tests). The fourth hook, symmetric with {@link JwtHook} /\n * {@link OidcHook} / {@link WebhookAuthCallback}:\n *\n * ```typescript\n * // AppModule.ts, beside the CompanyJwtHook binding\n * options.bind(API_KEY_HOOK).to(OneTabletApiKeyHook);\n * ```\n *\n * When NO ApiKeyHook is bound, the framework {@link AuthFilter} 401s every `@WpAuthApiKey` endpoint,\n * exactly as it does for an unbound JwtHook. There is no framework default and there never will be\n * one: the key regime lives in the app's datastore, under the app's hashing scheme, behind the app's\n * choice of header names.\n *\n * THE ONE THING THIS HAS THAT `JwtHook.parseJwt` DOES NOT: it receives the whole {@link HttpRequest},\n * not one pre-extracted token. A real key regime validates the key TOGETHER WITH a second header — the\n * organization the caller is acting for — and a hook handed one header's value physically cannot do\n * that cross-check. The framework therefore EXTRACTS no api-key header: which headers carry the\n * credential is the app's business, and `getHeader` / `getHeaderValues` read as many as the regime\n * needs. The contract's `credentials` list DECLARES those header names for readers and spec\n * generators — it is documentation of the regime, never an instruction to this hook, so the hook\n * stays the single place the pair is actually validated. (Being ASYNC is no longer a difference — every hook here is, and for the same reason: an\n * app's strategy reaches the network.)\n *\n * ONE hook serves EVERY regime: `regime` selects which, so a server with a partner-api regime and an\n * internal-tooling regime switches on it rather than binding a token per regime.\n */\nexport abstract class ApiKeyHook {\n /**\n * AUTHENTICATE one inbound request. Return who the caller is plus the {@link AuthenticatedCaller.entries}\n * the framework seeds into `RequestContext` via `putTrusted`, or throw\n * {@link ApiUnauthorizedError} to deny.\n *\n * NOTE the seeded entries are TRUSTED context keys, so return only what THIS hook proved from the\n * credential it just verified. Anything the caller merely asserted on the wire is not admitted by\n * `@WpAuthApiKey` — the mode is deliberately caller-NOT-verified (see `AuthFilter.verifiesCaller`),\n * because a customer is not an internal service.\n *\n * @param regime the first argument of the contract's `@WpAuthApiKey(regime, credentials)` — which key\n * regime this route belongs to.\n * @param request the inbound request; read as many headers as the regime needs with\n * `getHeader` / `getHeaderValues`, either by raw name or by {@link ContextKey}.\n */\n abstract verifyApiKey(regime: string, request: HttpRequest): Promise<AuthenticatedCaller>;\n}\n\n/**\n * DI identifier for the optional {@link ApiKeyHook} binding. It is a Symbol (not the class) so the app\n * container's inversify autobind never auto-constructs this token, keeping `@optional() @inject(API_KEY_HOOK)`\n * correct — undefined when unbound. The ApiKeyHook class stays the TYPE and the impl base.\n */\n// webpieces-disable no-symbol-di-tokens -- optional DI token: must be a Symbol so the app container's autobind never auto-constructs this token, keeping @optional() @inject(...) correct (undefined when unbound)\nexport const API_KEY_HOOK = Symbol.for('ApiKeyHook');\n"]}
1
+ {"version":3,"file":"AuthHooks.js","sourceRoot":"","sources":["../../../../../packages/http/http-routing/src/AuthHooks.ts"],"names":[],"mappings":";;;AAGA,gFAAgF;AAChF,MAAa,SAAS;IAEE;IACA;IAFpB,YACoB,KAAa,EACb,qBAA6B;QAD7B,UAAK,GAAL,KAAK,CAAQ;QACb,0BAAqB,GAArB,qBAAqB,CAAQ;QAE7C,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE;YAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QAChF,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,qBAAqB,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,KAAK,CAAC,wDAAwD,CAAC,CAAC;QAC9E,CAAC;IACL,CAAC;CACJ;AAVD,8BAUC;AAED,+FAA+F;AAC/F,MAAsB,OAAO;CAG5B;AAHD,0BAGC;AAED;;;;GAIG;AACH,mNAAmN;AACtM,QAAA,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;AAE9C;;;;;;;;;GASG;AACH,MAAsB,QAAQ;CAE7B;AAFD,4BAEC;AAED;;;;GAIG;AACH,mNAAmN;AACtM,QAAA,SAAS,GAAG,MAAM,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;AAEhD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAsB,mBAAmB;CAuBxC;AAvBD,kDAuBC;AAED;;;;GAIG;AACH,mNAAmN;AACtM,QAAA,qBAAqB,GAAG,MAAM,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC;AAEvE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,MAAsB,UAAU;CAiB/B;AAjBD,gCAiBC;AAED;;;;GAIG;AACH,mNAAmN;AACtM,QAAA,YAAY,GAAG,MAAM,CAAC,GAAG,CAAC,YAAY,CAAC,CAAC","sourcesContent":["import { HttpRequest, RawHttpRequest } from '@webpieces/core-context';\nimport { AuthenticatedCaller, AuthenticatedMachineIdentity } from './AuthConfig';\n\n/** Framework-normalized result from an application-owned JWT mint operation. */\nexport class MintedJwt {\n constructor(\n public readonly token: string,\n public readonly expiresAtEpochSeconds: number,\n ) {\n if (token.trim() === '') throw new Error('Minted JWT token must be non-empty.');\n if (!Number.isFinite(expiresAtEpochSeconds)) {\n throw new Error('Minted JWT expiry must be a finite epoch-second value.');\n }\n }\n}\n\n/** Optional application JWT credential authority. Authorization lives in AuthorizationHook. */\nexport abstract class JwtHook<TMintRequest> {\n abstract mint(request: TMintRequest): Promise<MintedJwt>;\n abstract parseJwt(token: string): Promise<AuthenticatedCaller>;\n}\n\n/**\n * DI identifier for the optional {@link JwtHook} binding. It is a Symbol (not the class) so the app\n * container's inversify autobind never auto-constructs this token, keeping `@optional() @inject(JWT_HOOK)`\n * correct — undefined when unbound. The JwtHook class stays the TYPE and the impl base.\n */\n// webpieces-disable no-symbol-di-tokens -- optional DI token: must be a Symbol so the app container's autobind never auto-constructs this token, keeping @optional() @inject(...) correct (undefined when unbound)\nexport const JWT_HOOK = Symbol.for('JwtHook');\n\n/**\n * OidcHook - the OPTIONAL override for Google OIDC service-to-service verification. Its DI token is the\n * {@link OIDC_HOOK} Symbol injected via `@inject(OIDC_HOOK)` (a Symbol, because the app container uses\n * autobind; rebindable in tests). Bind one ONLY to customize the caller policy — e.g. an app that reads an\n * `ALLOWED_OIDC_CALLERS` env var at its composition root and enforces that allow-list. When NO\n * OidcHook is bound, the framework {@link AuthFilter} runs the built-in {@link DefaultOidcVerifier}\n * directly, so a server that wires nothing still verifies Google OIDC against its `oidc(...callers)`\n * (else trusts the edge — any Google-signed caller). `verifyOidc` verifies the token against `callers`;\n * throw on failure.\n */\nexport abstract class OidcHook {\n abstract verifyOidc(token: string, callers: string[]): Promise<AuthenticatedMachineIdentity>;\n}\n\n/**\n * DI identifier for the optional {@link OidcHook} binding. It is a Symbol (not the class) so the app\n * container's inversify autobind never auto-constructs this token, keeping `@optional() @inject(OIDC_HOOK)`\n * correct — undefined when unbound. The OidcHook class stays the TYPE and the impl base.\n */\n// webpieces-disable no-symbol-di-tokens -- optional DI token: must be a Symbol so the app container's autobind never auto-constructs this token, keeping @optional() @inject(...) correct (undefined when unbound)\nexport const OIDC_HOOK = Symbol.for('OidcHook');\n\n/**\n * WebhookAuthCallback - the OPTIONAL mechanism behind `webhook(name)`: prove that an inbound request was\n * really authored by the outside vendor the contract names. Its DI token is the {@link WEBHOOK_AUTH_CALLBACK}\n * Symbol injected via `@inject(WEBHOOK_AUTH_CALLBACK)` (a Symbol, because the app container uses autobind;\n * rebindable in tests). The third hook, symmetric with {@link JwtHook} / {@link OidcHook}:\n *\n * ```typescript\n * // AppModule.ts, beside the CompanyJwtHook binding\n * options.bind(WEBHOOK_AUTH_CALLBACK).to(CompanyWebhookAuthCallback);\n * ```\n *\n * When NO WebhookAuthCallback is bound, the framework {@link AuthFilter} 401s every `webhook(...)` endpoint,\n * exactly as it does for an unbound JwtHook. There is no framework default and there never will be\n * one: silently allowing an unverified webhook is the single default that must not exist, and the\n * framework ships no vendor crypto by design (see {@link webhook} for why reimplementing five\n * vendors' schemes is a losing trade).\n *\n * ONE hook serves EVERY vendor: `name` selects which, so an app with a Sentry hook and a Twilio hook\n * switches on it rather than binding a token per vendor. What arrives is enough of the raw request to\n * call the vendor's OWN validator — `request.raw.rawBody` for a body-signing vendor (Sentry, GitHub,\n * Stripe, Slack), `request.raw.absoluteUrl` for one that signs the url instead (Twilio).\n */\nexport abstract class WebhookAuthCallback {\n /**\n * Verify ONE inbound request. Return the {@link AuthenticatedCaller} the vendor's signature\n * proved; throw {@link ApiUnauthorizedError} to deny.\n *\n * IT RETURNS A CALLER, not `void`, for the same reason {@link ApiKeyHook.verifyApiKey} does: once\n * the signature checks out, the payload's vendor account is a PROVEN fact, and a hook that could\n * only return `void` had no way to say so. The framework seeds `entries` with\n * `RequestContext.putTrusted` exactly as it does for a jwt or api-key caller, so a controller\n * reads which vendor account this webhook is for off the context instead of re-deriving it.\n *\n * Return only what THIS hook proved from the signature it just verified. `webhook` remains\n * caller-NOT-verified (see `AuthFilter.verifiesCaller`): a vendor is not a peer service, so\n * nothing the vendor merely ASSERTED on the wire is admitted.\n *\n * @param name the string on the contract's `webhook(name)` — which vendor this route is.\n * @param request the transport-neutral request, narrowed to {@link RawHttpRequest}: `request.raw`\n * holds the verbatim bytes + absolute url and is PRESENT, never optional.\n * `webhook(...)` requires `@Endpoint(..., { rawBody: true })` at wiring time, and\n * AuthFilter 401s rather than calling this hook with nothing to check — so an\n * implementation never writes `raw!` or a guard of its own.\n */\n abstract verifyWebhook(name: string, request: RawHttpRequest): Promise<AuthenticatedCaller>;\n}\n\n/**\n * DI identifier for the optional {@link WebhookAuthCallback} binding. It is a Symbol (not the class) so the app\n * container's inversify autobind never auto-constructs this token, keeping `@optional() @inject(WEBHOOK_AUTH_CALLBACK)`\n * correct — undefined when unbound. The WebhookAuthCallback class stays the TYPE and the impl base.\n */\n// webpieces-disable no-symbol-di-tokens -- optional DI token: must be a Symbol so the app container's autobind never auto-constructs this token, keeping @optional() @inject(...) correct (undefined when unbound)\nexport const WEBHOOK_AUTH_CALLBACK = Symbol.for('WebhookAuthCallback');\n\n/**\n * ApiKeyHook - the OPTIONAL mechanism behind `apiKey(regime, credentials)`: authenticate a\n * CUSTOMER-held api key\n * against the app's own datastore and return the context to seed. Its DI token is the\n * {@link API_KEY_HOOK} Symbol injected via `@inject(API_KEY_HOOK)` (a Symbol, because the app container\n * uses autobind; rebindable in tests). The fourth hook, symmetric with {@link JwtHook} /\n * {@link OidcHook} / {@link WebhookAuthCallback}:\n *\n * ```typescript\n * // AppModule.ts, beside the CompanyJwtHook binding\n * options.bind(API_KEY_HOOK).to(OneTabletApiKeyHook);\n * ```\n *\n * When NO ApiKeyHook is bound, the framework {@link AuthFilter} 401s every `apiKey(...)` endpoint,\n * exactly as it does for an unbound JwtHook. There is no framework default and there never will be\n * one: the key regime lives in the app's datastore, under the app's hashing scheme, behind the app's\n * choice of header names.\n *\n * THE ONE THING THIS HAS THAT `JwtHook.parseJwt` DOES NOT: it receives the whole {@link HttpRequest},\n * not one pre-extracted token. A real key regime validates the key TOGETHER WITH a second header — the\n * organization the caller is acting for — and a hook handed one header's value physically cannot do\n * that cross-check. The framework therefore EXTRACTS no api-key header: which headers carry the\n * credential is the app's business, and `getHeader` / `getHeaderValues` read as many as the regime\n * needs. The contract's `credentials` list DECLARES those header names for readers and spec\n * generators — it is documentation of the regime, never an instruction to this hook, so the hook\n * stays the single place the pair is actually validated. (Being ASYNC is no longer a difference — every hook here is, and for the same reason: an\n * app's strategy reaches the network.)\n *\n * ONE hook serves EVERY regime: `regime` selects which, so a server with a partner-api regime and an\n * internal-tooling regime switches on it rather than binding a token per regime.\n */\nexport abstract class ApiKeyHook {\n /**\n * AUTHENTICATE one inbound request. Return who the caller is plus the {@link AuthenticatedCaller.entries}\n * the framework seeds into `RequestContext` via `putTrusted`, or throw\n * {@link ApiUnauthorizedError} to deny.\n *\n * NOTE the seeded entries are TRUSTED context keys, so return only what THIS hook proved from the\n * credential it just verified. Anything the caller merely asserted on the wire is not admitted by\n * `apiKey(...)` — the mode is deliberately caller-NOT-verified (see `AuthFilter.verifiesCaller`),\n * because a customer is not an internal service.\n *\n * @param regime the first argument of the contract's `apiKey(regime, credentials)` — which key\n * regime this route belongs to.\n * @param request the inbound request; read as many headers as the regime needs with\n * `getHeader` / `getHeaderValues`, either by raw name or by {@link ContextKey}.\n */\n abstract verifyApiKey(regime: string, request: HttpRequest): Promise<AuthenticatedCaller>;\n}\n\n/**\n * DI identifier for the optional {@link ApiKeyHook} binding. It is a Symbol (not the class) so the app\n * container's inversify autobind never auto-constructs this token, keeping `@optional() @inject(API_KEY_HOOK)`\n * correct — undefined when unbound. The ApiKeyHook class stays the TYPE and the impl base.\n */\n// webpieces-disable no-symbol-di-tokens -- optional DI token: must be a Symbol so the app container's autobind never auto-constructs this token, keeping @optional() @inject(...) correct (undefined when unbound)\nexport const API_KEY_HOOK = Symbol.for('ApiKeyHook');\n"]}
@@ -1,7 +1,13 @@
1
1
  import { AuthenticatedCaller } from './AuthConfig';
2
2
  /** Shared trusted principal publication and inbound header reconciliation for HTTP and MCP. */
3
3
  export declare class AuthenticatedCallerContext {
4
+ private static readonly establishedScopes;
5
+ private static readonly publishedScopes;
6
+ private static readonly completedScopes;
7
+ static hasEstablishedIngress(): boolean;
8
+ static hasCompletedAuthentication(): boolean;
4
9
  publish(caller: AuthenticatedCaller): void;
10
+ private validateCanonicalIdentity;
5
11
  reconcileWireTrust(callerVerified: boolean): void;
6
12
  /**
7
13
  * On a browser-reachable route, an inbound trusted header must match what the authenticator
@@ -3,11 +3,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AuthenticatedCallerContext = void 0;
4
4
  const core_context_1 = require("@webpieces/core-context");
5
5
  const core_util_1 = require("@webpieces/core-util");
6
+ const AuthorizationHook_1 = require("./AuthorizationHook");
6
7
  const AuthConfig_1 = require("./AuthConfig");
7
8
  const log = core_util_1.LogManager.getLogger('AuthenticatedCallerContext');
8
9
  /** Shared trusted principal publication and inbound header reconciliation for HTTP and MCP. */
9
10
  class AuthenticatedCallerContext {
11
+ static establishedScopes = new WeakSet();
12
+ static publishedScopes = new WeakSet();
13
+ static completedScopes = new WeakSet();
14
+ // webpieces-disable no-function-outside-class -- stateless contract validation or scope metadata reader used before DI registration
15
+ static hasEstablishedIngress() {
16
+ return this.establishedScopes.has(core_context_1.RequestContext.activeScopeIdentity());
17
+ }
18
+ // webpieces-disable no-function-outside-class -- authentication completion is scoped metadata, not a transferable context value
19
+ static hasCompletedAuthentication() {
20
+ return this.completedScopes.has(core_context_1.RequestContext.activeScopeIdentity());
21
+ }
10
22
  publish(caller) {
23
+ this.validateCanonicalIdentity(caller);
11
24
  for (const entry of caller.entries) {
12
25
  // ContextTuple.key is a TRUSTED key by type, so this is the one sanctioned write of a
13
26
  // proven identity: the app's hook derived it from a credential we just verified.
@@ -16,6 +29,30 @@ class AuthenticatedCallerContext {
16
29
  // A real TRUSTED ContextKey, not a raw string slot: the caller IS the framework's own proof,
17
30
  // so it is written with the same typed verb every other proven value goes through.
18
31
  core_context_1.RequestContext.putTrusted(AuthConfig_1.AUTHENTICATED_CALLER_KEY, caller);
32
+ if (caller.userId !== undefined) {
33
+ core_context_1.RequestContext.putTrusted(core_util_1.WebpiecesCoreHeaders.USER_ID, caller.userId);
34
+ core_context_1.RequestContext.putTrusted(core_util_1.WebpiecesCoreHeaders.USER_ROLES, JSON.stringify(caller.roles));
35
+ }
36
+ if (caller.machine) {
37
+ core_context_1.RequestContext.putTrusted(AuthorizationHook_1.VERIFIED_MACHINE_CALLER, new AuthorizationHook_1.VerifiedMachineCaller(caller.machine.mechanism, caller.machine.identity));
38
+ }
39
+ AuthenticatedCallerContext.publishedScopes.add(core_context_1.RequestContext.activeScopeIdentity());
40
+ }
41
+ validateCanonicalIdentity(caller) {
42
+ if ((caller.userId !== undefined && (typeof caller.userId !== 'string' || !caller.userId.trim())) ||
43
+ (caller.userId === undefined && !caller.machine)) {
44
+ throw new core_util_1.ApiImplementationError('Verified caller must contain a nonempty user ID or verified machine identity.');
45
+ }
46
+ if (!Array.isArray(caller.roles) || !caller.roles.every((role) => typeof role === 'string' && role.trim().length > 0) ||
47
+ (caller.userId === undefined && caller.roles.length > 0)) {
48
+ throw new core_util_1.ApiImplementationError('Verified caller roles must be nonempty strings belonging to a verified user.');
49
+ }
50
+ for (const entry of caller.entries) {
51
+ if ((entry.key.name === core_util_1.WebpiecesCoreHeaders.USER_ID.name && entry.value !== caller.userId) ||
52
+ (entry.key.name === core_util_1.WebpiecesCoreHeaders.USER_ROLES.name && entry.value !== JSON.stringify(caller.roles))) {
53
+ throw new core_util_1.ApiImplementationError('Canonical user ID/roles entries disagree with the verified caller.');
54
+ }
55
+ }
19
56
  }
20
57
  reconcileWireTrust(callerVerified) {
21
58
  const pending = core_context_1.PendingWireTrust.takeAll();
@@ -27,6 +64,13 @@ class AuthenticatedCallerContext {
27
64
  this.requireVouched(item);
28
65
  }
29
66
  }
67
+ const machine = core_context_1.RequestContext.getTrusted(AuthorizationHook_1.VERIFIED_MACHINE_CALLER);
68
+ const scope = core_context_1.RequestContext.activeScopeIdentity();
69
+ AuthenticatedCallerContext.completedScopes.add(scope);
70
+ if (AuthenticatedCallerContext.publishedScopes.has(scope) ||
71
+ (machine instanceof AuthorizationHook_1.VerifiedMachineCaller && machine.isCurrentHop())) {
72
+ AuthenticatedCallerContext.establishedScopes.add(scope);
73
+ }
30
74
  }
31
75
  /**
32
76
  * On a browser-reachable route, an inbound trusted header must match what the authenticator
@@ -1 +1 @@
1
- {"version":3,"file":"AuthenticatedCallerContext.js","sourceRoot":"","sources":["../../../../../packages/http/http-routing/src/AuthenticatedCallerContext.ts"],"names":[],"mappings":";;;AAAA,0DAAgG;AAChG,oDAAwE;AACxE,6CAA6E;AAE7E,MAAM,GAAG,GAAG,sBAAU,CAAC,SAAS,CAAC,4BAA4B,CAAC,CAAC;AAE/D,+FAA+F;AAC/F,MAAa,0BAA0B;IACnC,OAAO,CAAC,MAA2B;QAC/B,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC;YACjC,sFAAsF;YACtF,iFAAiF;YACjF,6BAAc,CAAC,UAAU,CAAC,KAAK,CAAC,GAAG,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;QACtD,CAAC;QACD,6FAA6F;QAC7F,mFAAmF;QACnF,6BAAc,CAAC,UAAU,CAAC,qCAAwB,EAAE,MAAM,CAAC,CAAC;IAChE,CAAC;IAED,kBAAkB,CAAC,cAAuB;QACtC,MAAM,OAAO,GAAG,+BAAgB,CAAC,OAAO,EAAE,CAAC;QAC3C,KAAK,MAAM,IAAI,IAAI,OAAO,EAAE,CAAC;YACzB,IAAI,cAAc,EAAE,CAAC;gBACjB,6BAAc,CAAC,UAAU,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;YACpD,CAAC;iBAAM,CAAC;gBACJ,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,CAAC;YAC9B,CAAC;QACL,CAAC;IACL,CAAC;IAED;;;;;;;;;;;;;;;OAeG;IACK,cAAc,CAAC,IAAyB;QAC5C,MAAM,OAAO,GAAG,6BAAc,CAAC,UAAU,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACpD,IAAI,OAAO,KAAK,IAAI,CAAC,KAAK,EAAE,CAAC;YACzB,OAAO;QACX,CAAC;QACD,GAAG,CAAC,KAAK,CACL,sBAAsB,IAAI,CAAC,GAAG,CAAC,UAAU,kDAAkD;YACvF,kDAAkD;YAClD,CAAC,OAAO,KAAK,SAAS;gBAClB,CAAC,CAAC,2BAA2B;gBAC7B,CAAC,CAAC,2BAA2B,CAAC;YAClC,GAAG,CACV,CAAC;QACF,MAAM,IAAI,gCAAoB,CAC1B,WAAW,IAAI,CAAC,GAAG,CAAC,UAAU,qDAAqD,CACtF,CAAC;IACN,CAAC;CACJ;AAxDD,gEAwDC","sourcesContent":["import { PendingWireTrust, PendingTrustedValue, RequestContext } from '@webpieces/core-context';\nimport { ApiUnauthorizedError, LogManager } from '@webpieces/core-util';\nimport { AuthenticatedCaller, AUTHENTICATED_CALLER_KEY } from './AuthConfig';\n\nconst log = LogManager.getLogger('AuthenticatedCallerContext');\n\n/** Shared trusted principal publication and inbound header reconciliation for HTTP and MCP. */\nexport class AuthenticatedCallerContext {\n publish(caller: AuthenticatedCaller): void {\n for (const entry of caller.entries) {\n // ContextTuple.key is a TRUSTED key by type, so this is the one sanctioned write of a\n // proven identity: the app's hook derived it from a credential we just verified.\n RequestContext.putTrusted(entry.key, entry.value);\n }\n // A real TRUSTED ContextKey, not a raw string slot: the caller IS the framework's own proof,\n // so it is written with the same typed verb every other proven value goes through.\n RequestContext.putTrusted(AUTHENTICATED_CALLER_KEY, caller);\n }\n\n reconcileWireTrust(callerVerified: boolean): void {\n const pending = PendingWireTrust.takeAll();\n for (const item of pending) {\n if (callerVerified) {\n RequestContext.putTrusted(item.key, item.value);\n } else {\n this.requireVouched(item);\n }\n }\n }\n\n /**\n * On a browser-reachable route, an inbound trusted header must match what the authenticator\n * itself derived, or the request dies. Both failure shapes are rejections, not repairs:\n *\n * - DIFFERENT value — the caller said `alice`, the credential says `bob`. Silently letting the\n * credential win is not safe, because upstream rate limiters commonly bucket on the header\n * rather than the token: the request was already counted against the wrong principal, so\n * every forged header would be a free rate-limit bypass. No honest caller contradicts its own\n * credential.\n * - NOTHING vouched for it — nobody derived this key at all, so there is no evidence behind a\n * value a stranger typed. This is the common case, not the exotic one: the framework's\n * {@link DefaultJwtHook} stamps NO entries, and an app hook (jwt or api-key) only stamps the keys it can prove,\n * so any other trusted key a caller sends lands here.\n *\n * The pending value is discarded either way — the throw is what leaves the request.\n */\n private requireVouched(item: PendingTrustedValue): void {\n const vouched = RequestContext.getTrusted(item.key);\n if (vouched === item.value) {\n return;\n }\n log.error(\n `Rejecting inbound '${item.key.httpHeader}': it is a TRUSTED context key, this route does ` +\n `not authenticate its caller, and the credential ` +\n (vouched === undefined\n ? 'vouched for no such value'\n : 'derived a different value') +\n '.',\n );\n throw new ApiUnauthorizedError(\n `Header '${item.key.httpHeader}' cannot be supplied by the caller on this endpoint`,\n );\n }\n}\n"]}
1
+ {"version":3,"file":"AuthenticatedCallerContext.js","sourceRoot":"","sources":["../../../../../packages/http/http-routing/src/AuthenticatedCallerContext.ts"],"names":[],"mappings":"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","sourcesContent":["import { PendingWireTrust, PendingTrustedValue, RequestContext } from '@webpieces/core-context';\nimport { ApiUnauthorizedError, ApiImplementationError, LogManager, WebpiecesCoreHeaders } from '@webpieces/core-util';\nimport { VERIFIED_MACHINE_CALLER, VerifiedMachineCaller } from './AuthorizationHook';\nimport { AuthenticatedCaller, AUTHENTICATED_CALLER_KEY } from './AuthConfig';\n\nconst log = LogManager.getLogger('AuthenticatedCallerContext');\n\n/** Shared trusted principal publication and inbound header reconciliation for HTTP and MCP. */\nexport class AuthenticatedCallerContext {\n private static readonly establishedScopes = new WeakSet<object>();\n private static readonly publishedScopes = new WeakSet<object>();\n private static readonly completedScopes = new WeakSet<object>();\n\n // webpieces-disable no-function-outside-class -- stateless contract validation or scope metadata reader used before DI registration\n static hasEstablishedIngress(): boolean {\n return this.establishedScopes.has(RequestContext.activeScopeIdentity());\n }\n\n // webpieces-disable no-function-outside-class -- authentication completion is scoped metadata, not a transferable context value\n static hasCompletedAuthentication(): boolean {\n return this.completedScopes.has(RequestContext.activeScopeIdentity());\n }\n\n publish(caller: AuthenticatedCaller): void {\n this.validateCanonicalIdentity(caller);\n for (const entry of caller.entries) {\n // ContextTuple.key is a TRUSTED key by type, so this is the one sanctioned write of a\n // proven identity: the app's hook derived it from a credential we just verified.\n RequestContext.putTrusted(entry.key, entry.value);\n }\n // A real TRUSTED ContextKey, not a raw string slot: the caller IS the framework's own proof,\n // so it is written with the same typed verb every other proven value goes through.\n RequestContext.putTrusted(AUTHENTICATED_CALLER_KEY, caller);\n if (caller.userId !== undefined) {\n RequestContext.putTrusted(WebpiecesCoreHeaders.USER_ID, caller.userId);\n RequestContext.putTrusted(WebpiecesCoreHeaders.USER_ROLES, JSON.stringify(caller.roles));\n }\n if (caller.machine) {\n RequestContext.putTrusted(VERIFIED_MACHINE_CALLER, new VerifiedMachineCaller(caller.machine.mechanism, caller.machine.identity));\n }\n AuthenticatedCallerContext.publishedScopes.add(RequestContext.activeScopeIdentity());\n }\n\n private validateCanonicalIdentity(caller: AuthenticatedCaller): void {\n if ((caller.userId !== undefined && (typeof caller.userId !== 'string' || !caller.userId.trim())) ||\n (caller.userId === undefined && !caller.machine)) {\n throw new ApiImplementationError('Verified caller must contain a nonempty user ID or verified machine identity.');\n }\n if (!Array.isArray(caller.roles) || !caller.roles.every((role: string) => typeof role === 'string' && role.trim().length > 0) ||\n (caller.userId === undefined && caller.roles.length > 0)) {\n throw new ApiImplementationError('Verified caller roles must be nonempty strings belonging to a verified user.');\n }\n for (const entry of caller.entries) {\n if ((entry.key.name === WebpiecesCoreHeaders.USER_ID.name && entry.value !== caller.userId) ||\n (entry.key.name === WebpiecesCoreHeaders.USER_ROLES.name && entry.value !== JSON.stringify(caller.roles))) {\n throw new ApiImplementationError('Canonical user ID/roles entries disagree with the verified caller.');\n }\n }\n }\n\n reconcileWireTrust(callerVerified: boolean): void {\n const pending = PendingWireTrust.takeAll();\n for (const item of pending) {\n if (callerVerified) {\n RequestContext.putTrusted(item.key, item.value);\n } else {\n this.requireVouched(item);\n }\n }\n const machine = RequestContext.getTrusted(VERIFIED_MACHINE_CALLER);\n const scope = RequestContext.activeScopeIdentity();\n AuthenticatedCallerContext.completedScopes.add(scope);\n if (AuthenticatedCallerContext.publishedScopes.has(scope) ||\n (machine instanceof VerifiedMachineCaller && machine.isCurrentHop())) {\n AuthenticatedCallerContext.establishedScopes.add(scope);\n }\n }\n\n /**\n * On a browser-reachable route, an inbound trusted header must match what the authenticator\n * itself derived, or the request dies. Both failure shapes are rejections, not repairs:\n *\n * - DIFFERENT value — the caller said `alice`, the credential says `bob`. Silently letting the\n * credential win is not safe, because upstream rate limiters commonly bucket on the header\n * rather than the token: the request was already counted against the wrong principal, so\n * every forged header would be a free rate-limit bypass. No honest caller contradicts its own\n * credential.\n * - NOTHING vouched for it — nobody derived this key at all, so there is no evidence behind a\n * value a stranger typed. This is the common case, not the exotic one: the framework's\n * {@link DefaultJwtHook} stamps NO entries, and an app hook (jwt or api-key) only stamps the keys it can prove,\n * so any other trusted key a caller sends lands here.\n *\n * The pending value is discarded either way — the throw is what leaves the request.\n */\n private requireVouched(item: PendingTrustedValue): void {\n const vouched = RequestContext.getTrusted(item.key);\n if (vouched === item.value) {\n return;\n }\n log.error(\n `Rejecting inbound '${item.key.httpHeader}': it is a TRUSTED context key, this route does ` +\n `not authenticate its caller, and the credential ` +\n (vouched === undefined\n ? 'vouched for no such value'\n : 'derived a different value') +\n '.',\n );\n throw new ApiUnauthorizedError(\n `Header '${item.key.httpHeader}' cannot be supplied by the caller on this endpoint`,\n );\n }\n}\n"]}
@@ -0,0 +1,34 @@
1
+ import { AuthorizationRequirement, ContextKey } from '@webpieces/core-util';
2
+ /** An application validates its own policy; the framework supplies no organization semantics. */
3
+ export declare abstract class AuthorizationHook<TPolicy> {
4
+ abstract validatePolicy(value: unknown): TPolicy;
5
+ abstract authorize(policy: TPolicy): Promise<void>;
6
+ compile(value: unknown): CompiledAuthorizationPolicy;
7
+ }
8
+ export declare class CompiledAuthorizationPolicy {
9
+ private readonly evaluation;
10
+ constructor(evaluation: () => Promise<void>);
11
+ authorize(): Promise<void>;
12
+ }
13
+ export declare const AUTHORIZATION_HOOK: unique symbol;
14
+ /** Current-hop machine proof is context-only; delegated headers never create it. */
15
+ export declare class VerifiedMachineCaller {
16
+ readonly mechanism: string;
17
+ readonly identity: string;
18
+ private readonly scopeIdentity;
19
+ isCurrentHop(): boolean;
20
+ constructor(mechanism: string, identity: string);
21
+ }
22
+ export declare const VERIFIED_MACHINE_CALLER: ContextKey<VerifiedMachineCaller, "trusted">;
23
+ /** One compiled policy governs receiving endpoints, MCP visibility, and served documents. */
24
+ export declare class AuthorizationService {
25
+ private readonly hook?;
26
+ private readonly compiled;
27
+ constructor(hook?: AuthorizationHook<never> | undefined);
28
+ validate(requirement: AuthorizationRequirement<unknown>): void;
29
+ authorize(requirement: AuthorizationRequirement<unknown>): Promise<void>;
30
+ }
31
+ /** Canonical roles use a JSON string array, including [] for a verified user with no roles. */
32
+ export declare class CanonicalUserRoles {
33
+ static read(): readonly string[];
34
+ }
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CanonicalUserRoles = exports.AuthorizationService = exports.VERIFIED_MACHINE_CALLER = exports.VerifiedMachineCaller = exports.AUTHORIZATION_HOOK = exports.CompiledAuthorizationPolicy = exports.AuthorizationHook = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const inversify_1 = require("inversify");
6
+ const core_context_1 = require("@webpieces/core-context");
7
+ const core_util_1 = require("@webpieces/core-util");
8
+ /** An application validates its own policy; the framework supplies no organization semantics. */
9
+ class AuthorizationHook {
10
+ // webpieces-disable no-any-unknown -- closes the validated generic value before the framework erases its type
11
+ compile(value) {
12
+ const policy = this.validatePolicy(value);
13
+ return new CompiledAuthorizationPolicy(() => this.authorize(policy));
14
+ }
15
+ }
16
+ exports.AuthorizationHook = AuthorizationHook;
17
+ class CompiledAuthorizationPolicy {
18
+ evaluation;
19
+ constructor(evaluation) {
20
+ this.evaluation = evaluation;
21
+ }
22
+ authorize() {
23
+ return this.evaluation();
24
+ }
25
+ }
26
+ exports.CompiledAuthorizationPolicy = CompiledAuthorizationPolicy;
27
+ // webpieces-disable no-symbol-di-tokens -- optional application hook must not be auto-bound by the framework container
28
+ exports.AUTHORIZATION_HOOK = Symbol.for('AuthorizationHook');
29
+ /** Current-hop machine proof is context-only; delegated headers never create it. */
30
+ class VerifiedMachineCaller {
31
+ mechanism;
32
+ identity;
33
+ scopeIdentity = core_context_1.RequestContext.activeScopeIdentity();
34
+ isCurrentHop() {
35
+ return this.scopeIdentity === core_context_1.RequestContext.activeScopeIdentity();
36
+ }
37
+ constructor(mechanism, identity) {
38
+ this.mechanism = mechanism;
39
+ this.identity = identity;
40
+ if (!mechanism.trim() || !identity.trim())
41
+ throw new Error('Verified machine evidence must name its mechanism and identity.');
42
+ }
43
+ }
44
+ exports.VerifiedMachineCaller = VerifiedMachineCaller;
45
+ exports.VERIFIED_MACHINE_CALLER = core_util_1.ContextKey.trusted('verifiedMachineCaller', 'established by the successful current-hop credential verifier', undefined, false, false);
46
+ /** One compiled policy governs receiving endpoints, MCP visibility, and served documents. */
47
+ let AuthorizationService = class AuthorizationService {
48
+ hook;
49
+ compiled = new WeakMap();
50
+ constructor(hook) {
51
+ this.hook = hook;
52
+ }
53
+ // webpieces-disable no-any-unknown -- the custom hook validates erased application policy at startup
54
+ validate(requirement) {
55
+ core_util_1.AuthorizationDeclaration.validate(requirement);
56
+ if (requirement.authType !== core_util_1.AuthorizationType.CUSTOM || this.compiled.has(requirement))
57
+ return;
58
+ if (!this.hook)
59
+ throw new Error('@WpAuthorization CUSTOM requires a bound AuthorizationHook (AUTHORIZATION_HOOK).');
60
+ this.compiled.set(requirement, this.hook.compile(requirement.appPolicy));
61
+ }
62
+ // webpieces-disable no-any-unknown -- standard branches are narrowed; custom policies execute their compiled typed closure
63
+ async authorize(requirement) {
64
+ this.validate(requirement);
65
+ const userId = core_context_1.RequestContext.getTrusted(core_util_1.WebpiecesCoreHeaders.USER_ID);
66
+ const isUser = typeof userId === 'string' && userId.trim().length > 0;
67
+ const machine = core_context_1.RequestContext.getTrusted(exports.VERIFIED_MACHINE_CALLER);
68
+ const isMachine = machine instanceof VerifiedMachineCaller && machine.isCurrentHop();
69
+ switch (requirement.authType) {
70
+ case core_util_1.AuthorizationType.ANONYMOUS: return;
71
+ case core_util_1.AuthorizationType.ALL_USERS:
72
+ if (isUser)
73
+ return;
74
+ break;
75
+ case core_util_1.AuthorizationType.SERVICE_ONLY:
76
+ if (isMachine)
77
+ return;
78
+ break;
79
+ case core_util_1.AuthorizationType.USERS_OR_SERVICES:
80
+ if (isUser || isMachine)
81
+ return;
82
+ break;
83
+ case core_util_1.AuthorizationType.ROLES:
84
+ if (isUser && requirement.roles.some((role) => CanonicalUserRoles.read().includes(role)))
85
+ return;
86
+ break;
87
+ case core_util_1.AuthorizationType.CUSTOM: {
88
+ const policy = this.compiled.get(requirement);
89
+ if (!policy)
90
+ throw new Error('CUSTOM authorization was not compiled at startup.');
91
+ await policy.authorize();
92
+ return;
93
+ }
94
+ }
95
+ throw new core_util_1.ApiForbiddenError(`Caller does not satisfy ${requirement.authType} authorization.`);
96
+ }
97
+ };
98
+ exports.AuthorizationService = AuthorizationService;
99
+ exports.AuthorizationService = AuthorizationService = tslib_1.__decorate([
100
+ (0, core_context_1.provideFrameworkSingleton)(),
101
+ tslib_1.__param(0, (0, inversify_1.optional)()),
102
+ tslib_1.__param(0, (0, inversify_1.inject)(exports.AUTHORIZATION_HOOK)),
103
+ tslib_1.__metadata("design:paramtypes", [AuthorizationHook])
104
+ ], AuthorizationService);
105
+ /** Canonical roles use a JSON string array, including [] for a verified user with no roles. */
106
+ class CanonicalUserRoles {
107
+ // webpieces-disable no-function-outside-class -- stateless contract validation or scope metadata reader used before DI registration
108
+ static read() {
109
+ const encoded = core_context_1.RequestContext.getTrusted(core_util_1.WebpiecesCoreHeaders.USER_ROLES);
110
+ if (encoded === undefined)
111
+ throw new core_util_1.ApiForbiddenError('Verified user roles are missing.');
112
+ // webpieces-disable no-any-unknown -- JSON is validated before it becomes canonical roles
113
+ const value = JSON.parse(encoded);
114
+ if (!Array.isArray(value) || !value.every((role) => typeof role === 'string' && role.trim().length > 0)) {
115
+ throw new core_util_1.ApiForbiddenError('Canonical user roles must be a JSON string array.');
116
+ }
117
+ return value;
118
+ }
119
+ }
120
+ exports.CanonicalUserRoles = CanonicalUserRoles;
121
+ //# sourceMappingURL=AuthorizationHook.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AuthorizationHook.js","sourceRoot":"","sources":["../../../../../packages/http/http-routing/src/AuthorizationHook.ts"],"names":[],"mappings":"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","sourcesContent":["import { inject, optional } from 'inversify';\nimport { provideFrameworkSingleton, RequestContext } from '@webpieces/core-context';\nimport { ApiForbiddenError, AuthorizationDeclaration, AuthorizationRequirement, AuthorizationType, ContextKey, WebpiecesCoreHeaders } from '@webpieces/core-util';\n\n/** An application validates its own policy; the framework supplies no organization semantics. */\nexport abstract class AuthorizationHook<TPolicy> {\n // webpieces-disable no-any-unknown -- runtime application policy must be validated rather than cast to its generic type\n abstract validatePolicy(value: unknown): TPolicy;\n abstract authorize(policy: TPolicy): Promise<void>;\n\n // webpieces-disable no-any-unknown -- closes the validated generic value before the framework erases its type\n compile(value: unknown): CompiledAuthorizationPolicy {\n const policy = this.validatePolicy(value);\n return new CompiledAuthorizationPolicy(() => this.authorize(policy));\n }\n}\n\nexport class CompiledAuthorizationPolicy {\n constructor(private readonly evaluation: () => Promise<void>) {}\n\n authorize(): Promise<void> {\n return this.evaluation();\n }\n}\n\n// webpieces-disable no-symbol-di-tokens -- optional application hook must not be auto-bound by the framework container\nexport const AUTHORIZATION_HOOK = Symbol.for('AuthorizationHook');\n\n/** Current-hop machine proof is context-only; delegated headers never create it. */\nexport class VerifiedMachineCaller {\n private readonly scopeIdentity = RequestContext.activeScopeIdentity();\n\n isCurrentHop(): boolean {\n return this.scopeIdentity === RequestContext.activeScopeIdentity();\n }\n constructor(public readonly mechanism: string, public readonly identity: string) {\n if (!mechanism.trim() || !identity.trim()) throw new Error('Verified machine evidence must name its mechanism and identity.');\n }\n}\n\nexport const VERIFIED_MACHINE_CALLER = ContextKey.trusted<VerifiedMachineCaller>(\n 'verifiedMachineCaller', 'established by the successful current-hop credential verifier', undefined, false, false,\n);\n\n/** One compiled policy governs receiving endpoints, MCP visibility, and served documents. */\n@provideFrameworkSingleton()\nexport class AuthorizationService {\n private readonly compiled = new WeakMap<object, CompiledAuthorizationPolicy>();\n\n constructor(\n @optional() @inject(AUTHORIZATION_HOOK) private readonly hook?: AuthorizationHook<never>,\n ) {}\n\n // webpieces-disable no-any-unknown -- the custom hook validates erased application policy at startup\n validate(requirement: AuthorizationRequirement<unknown>): void {\n AuthorizationDeclaration.validate(requirement);\n if (requirement.authType !== AuthorizationType.CUSTOM || this.compiled.has(requirement)) return;\n if (!this.hook) throw new Error('@WpAuthorization CUSTOM requires a bound AuthorizationHook (AUTHORIZATION_HOOK).');\n this.compiled.set(requirement, this.hook.compile(requirement.appPolicy));\n }\n\n // webpieces-disable no-any-unknown -- standard branches are narrowed; custom policies execute their compiled typed closure\n async authorize(requirement: AuthorizationRequirement<unknown>): Promise<void> {\n this.validate(requirement);\n const userId = RequestContext.getTrusted(WebpiecesCoreHeaders.USER_ID);\n const isUser = typeof userId === 'string' && userId.trim().length > 0;\n const machine = RequestContext.getTrusted(VERIFIED_MACHINE_CALLER);\n const isMachine = machine instanceof VerifiedMachineCaller && machine.isCurrentHop();\n switch (requirement.authType) {\n case AuthorizationType.ANONYMOUS: return;\n case AuthorizationType.ALL_USERS:\n if (isUser) return;\n break;\n case AuthorizationType.SERVICE_ONLY:\n if (isMachine) return;\n break;\n case AuthorizationType.USERS_OR_SERVICES:\n if (isUser || isMachine) return;\n break;\n case AuthorizationType.ROLES:\n if (isUser && requirement.roles.some((role: string) => CanonicalUserRoles.read().includes(role))) return;\n break;\n case AuthorizationType.CUSTOM: {\n const policy = this.compiled.get(requirement);\n if (!policy) throw new Error('CUSTOM authorization was not compiled at startup.');\n await policy.authorize();\n return;\n }\n }\n throw new ApiForbiddenError(`Caller does not satisfy ${requirement.authType} authorization.`);\n }\n}\n\n/** Canonical roles use a JSON string array, including [] for a verified user with no roles. */\nexport class CanonicalUserRoles {\n // webpieces-disable no-function-outside-class -- stateless contract validation or scope metadata reader used before DI registration\n static read(): readonly string[] {\n const encoded = RequestContext.getTrusted(WebpiecesCoreHeaders.USER_ROLES);\n if (encoded === undefined) throw new ApiForbiddenError('Verified user roles are missing.');\n // webpieces-disable no-any-unknown -- JSON is validated before it becomes canonical roles\n const value: unknown = JSON.parse(encoded);\n if (!Array.isArray(value) || !value.every((role: unknown) => typeof role === 'string' && role.trim().length > 0)) {\n throw new ApiForbiddenError('Canonical user roles must be a JSON string array.');\n }\n return value as string[];\n }\n}\n"]}
@@ -0,0 +1,18 @@
1
+ import { RouteMetadata } from '@webpieces/core-util';
2
+ import { AuthorizationService } from './AuthorizationHook';
3
+ export type ApiDocumentValue = string | number | boolean | null | ApiDocumentValue[] | ApiDocumentObject;
4
+ export type ApiDocumentObject = {
5
+ [key: string]: ApiDocumentValue;
6
+ };
7
+ /** Project a private bundled catalog afresh inside each authenticated docs request. */
8
+ export declare class AuthorizedApiDocument {
9
+ private readonly policy;
10
+ private readonly routes;
11
+ private readonly methods;
12
+ constructor(policy: AuthorizationService, routes: readonly RouteMetadata[]);
13
+ project(catalog: ApiDocumentObject): Promise<ApiDocumentObject>;
14
+ private permits;
15
+ private pruneComponents;
16
+ private references;
17
+ private object;
18
+ }
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AuthorizedApiDocument = void 0;
4
+ const core_util_1 = require("@webpieces/core-util");
5
+ const AuthenticatedCallerContext_1 = require("./AuthenticatedCallerContext");
6
+ /** Project a private bundled catalog afresh inside each authenticated docs request. */
7
+ class AuthorizedApiDocument {
8
+ policy;
9
+ routes = new Map();
10
+ methods = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
11
+ constructor(policy, routes) {
12
+ this.policy = policy;
13
+ for (const route of routes) {
14
+ if (!route.authorization || !route.apiClass)
15
+ throw new core_util_1.ApiImplementationError('Document routes require canonical receiving endpoint metadata.');
16
+ policy.validate(route.authorization);
17
+ const id = `${route.apiClass.name}_${route.methodName}`;
18
+ if (this.routes.has(id))
19
+ throw new core_util_1.ApiImplementationError(`Duplicate document operation ${id}.`);
20
+ this.routes.set(id, route);
21
+ }
22
+ }
23
+ async project(catalog) {
24
+ if (!AuthenticatedCallerContext_1.AuthenticatedCallerContext.hasCompletedAuthentication())
25
+ throw new core_util_1.ApiImplementationError('Serve API documents inside completed authentication.');
26
+ const document = structuredClone(catalog);
27
+ const paths = this.object(document['paths']);
28
+ const tags = new Set();
29
+ for (const [path, value] of Object.entries(paths)) {
30
+ const item = this.object(value);
31
+ for (const method of Object.keys(item)) {
32
+ if (!this.methods.includes(method))
33
+ continue;
34
+ const operation = this.object(item[method]);
35
+ const id = operation['operationId'];
36
+ const route = typeof id === 'string' ? this.routes.get(id) : undefined;
37
+ if (!route?.authorization || route.path.replace(/:([A-Za-z_]\w*)/g, '{$1}') !== path || route.httpMethod.toLowerCase() !== method)
38
+ throw new core_util_1.ApiImplementationError(`Document operation ${String(id)} has no matching receiving endpoint policy.`);
39
+ if (!(await this.permits(route))) {
40
+ delete item[method];
41
+ continue;
42
+ }
43
+ const names = operation['tags'];
44
+ if (Array.isArray(names))
45
+ for (const name of names)
46
+ if (typeof name === 'string')
47
+ tags.add(name);
48
+ }
49
+ if (!Object.keys(item).some((method) => this.methods.includes(method)))
50
+ delete paths[path];
51
+ }
52
+ delete document['webhooks'];
53
+ if (Array.isArray(document['tags']))
54
+ document['tags'] = document['tags'].filter((tag) => {
55
+ const name = this.object(tag)['name'];
56
+ return typeof name === 'string' && tags.has(name);
57
+ });
58
+ this.pruneComponents(document);
59
+ return document;
60
+ }
61
+ async permits(route) {
62
+ if (!route.authorization)
63
+ throw new core_util_1.ApiImplementationError('Missing document authorization.');
64
+ // webpieces-disable no-unmanaged-exceptions -- document projection boundary turns only explicit policy denials into omitted operations; invariant failures propagate
65
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- explicit policy denials are omitted document operations; other failures propagate
66
+ try {
67
+ await this.policy.authorize(route.authorization);
68
+ return true;
69
+ }
70
+ catch (err) {
71
+ const error = (0, core_util_1.toError)(err);
72
+ if (error instanceof core_util_1.ApiForbiddenError)
73
+ return false;
74
+ throw error;
75
+ }
76
+ }
77
+ pruneComponents(document) {
78
+ if (document['components'] === undefined)
79
+ return;
80
+ const definitions = this.object(document['components']);
81
+ const reached = new Set();
82
+ const pending = [];
83
+ const roots = { ...document };
84
+ delete roots['components'];
85
+ this.references(roots, pending);
86
+ while (pending.length) {
87
+ const ref = pending.pop();
88
+ if (reached.has(ref))
89
+ continue;
90
+ reached.add(ref);
91
+ const match = /^#\/components\/([^/]+)\/([^/]+)$/.exec(ref);
92
+ if (!match)
93
+ throw new core_util_1.ApiImplementationError('Caller documents require a bundled catalog with local component references.');
94
+ const group = this.object(definitions[match[1]]);
95
+ const name = match[2].replace(/~1/g, '/').replace(/~0/g, '~');
96
+ if (group[name] === undefined)
97
+ throw new core_util_1.ApiImplementationError(`Document reference ${ref} is missing.`);
98
+ this.references(group[name], pending);
99
+ }
100
+ for (const [kind, value] of Object.entries(definitions)) {
101
+ const group = this.object(value);
102
+ for (const name of Object.keys(group)) {
103
+ const encoded = name.replace(/~/g, '~0').replace(/\//g, '~1');
104
+ if (!reached.has(`#/components/${kind}/${encoded}`))
105
+ delete group[name];
106
+ }
107
+ if (!Object.keys(group).length)
108
+ delete definitions[kind];
109
+ }
110
+ }
111
+ references(value, pending) {
112
+ if (!value || typeof value !== 'object')
113
+ return;
114
+ if (Array.isArray(value)) {
115
+ for (const child of value)
116
+ this.references(child, pending);
117
+ return;
118
+ }
119
+ if (typeof value['$ref'] === 'string')
120
+ pending.push(value['$ref']);
121
+ const security = value['security'];
122
+ if (Array.isArray(security))
123
+ for (const requirement of security) {
124
+ for (const name of Object.keys(this.object(requirement)))
125
+ pending.push(`#/components/securitySchemes/${name}`);
126
+ }
127
+ for (const child of Object.values(value))
128
+ this.references(child, pending);
129
+ }
130
+ object(value) {
131
+ if (!value || typeof value !== 'object' || Array.isArray(value))
132
+ throw new core_util_1.ApiImplementationError('API document requires object values.');
133
+ return value;
134
+ }
135
+ }
136
+ exports.AuthorizedApiDocument = AuthorizedApiDocument;
137
+ //# sourceMappingURL=AuthorizedApiDocument.js.map