@farthershore/backend 0.11.0 → 0.13.0
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 +66 -1
- package/dist/index.js +831 -23
- package/dist/testing/index.js +2716 -0
- package/dist/types/core/permissions.d.ts +37 -19
- package/dist/types/core/runtime.d.ts +36 -0
- package/dist/types/core/verifyRequest.d.ts +6 -5
- package/dist/types/index.d.ts +1 -1
- package/dist/types/response-metering.d.ts +45 -0
- package/dist/types/testing/devGateway.d.ts +36 -0
- package/dist/types/testing/devRuntime.d.ts +68 -0
- package/dist/types/testing/index.d.ts +8 -0
- package/dist/types/testing/keysFile.d.ts +30 -0
- package/dist/types/testing/personas.d.ts +104 -0
- package/dist/types/testing/prodGuard.d.ts +12 -0
- package/dist/types/testing/signers.d.ts +89 -0
- package/dist/types/testing/traceSink.d.ts +67 -0
- package/dist/types/testing/usageSink.d.ts +31 -0
- package/package.json +8 -4
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FartherShoreSignedContext } from "./verifyContext.js";
|
|
1
2
|
/**
|
|
2
3
|
* Thrown by {@link requirePermission} when the acting user lacks a permission.
|
|
3
4
|
* Distinct from {@link FartherShoreError} (which models signing/verification
|
|
@@ -13,25 +14,26 @@ export declare class FartherShorePermissionError extends Error {
|
|
|
13
14
|
}
|
|
14
15
|
/**
|
|
15
16
|
* Parse the comma-joined `x-fs-permissions` header into a permission list.
|
|
16
|
-
* Returns `undefined` when the header is absent (
|
|
17
|
-
* for a present-but-empty header
|
|
18
|
-
* grants). Whitespace-trimmed; empty
|
|
17
|
+
* Returns `undefined` when the header is absent (no permission source ⇒
|
|
18
|
+
* carrier-level DENY under FAR-723) and `[]` for a present-but-empty header
|
|
19
|
+
* (deny-all — an authenticated user with no grants). Whitespace-trimmed; empty
|
|
20
|
+
* segments dropped.
|
|
19
21
|
*/
|
|
20
22
|
export declare function parsePermissionHeader(raw: string | null | undefined): string[] | undefined;
|
|
21
23
|
/**
|
|
22
|
-
* Pure grant check
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* Pure grant check — a FAITHFUL COPY of the canonical `permissionGrants` in
|
|
25
|
+
* `@farthershore/contracts` (`rbac.ts`). Over a DEFINED array: a `"*"` entry
|
|
26
|
+
* grants everything, otherwise the key must be an exact member. Its
|
|
27
|
+
* `undefined → true` codomain is the PRIMITIVE's contract (kept byte-identical
|
|
28
|
+
* to contracts for parity); it is NOT the SDK's carrier policy. Under FAR-723
|
|
29
|
+
* the carrier gate {@link hasPermission} DENIES an absent permission set before
|
|
30
|
+
* this primitive is consulted, so absence never grants at the boundary.
|
|
26
31
|
*
|
|
27
|
-
* The
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* shared golden vector table, so the copy can't silently drift. The
|
|
33
|
-
* `undefined → grant-all` grace is documented here as the SDK's own policy; the
|
|
34
|
-
* shared primitive only covers the defined-array rule.
|
|
32
|
+
* The published SDK surface must stay contracts-free, so it can't import the
|
|
33
|
+
* canonical primitive. A TEST-ONLY parity guard (`permissions-parity.test.ts`,
|
|
34
|
+
* which CAN import contracts as a devDependency) asserts this copy agrees with
|
|
35
|
+
* the canonical primitive across a shared golden vector table, so the copy
|
|
36
|
+
* can't silently drift.
|
|
35
37
|
*/
|
|
36
38
|
export declare function permissionGrants(permissions: readonly string[] | undefined, key: string): boolean;
|
|
37
39
|
/**
|
|
@@ -39,8 +41,11 @@ export declare function permissionGrants(permissions: readonly string[] | undefi
|
|
|
39
41
|
* unified grammar: `"*"` (global), `"<subject>:*"` (subject wildcard), or the
|
|
40
42
|
* EXACT key. NO verb-class widening (class forms are expanded to concrete verbs
|
|
41
43
|
* server-side at save time). Superset of {@link permissionGrants} — it adds the
|
|
42
|
-
* `<subject>:*` rung
|
|
43
|
-
*
|
|
44
|
+
* `<subject>:*` rung. NOTE: the `granted === undefined → true` codomain here is
|
|
45
|
+
* the PRIMITIVE's contract (kept byte-identical to contracts for parity); it is
|
|
46
|
+
* NOT the SDK's carrier policy. Callers gate through {@link hasPermission},
|
|
47
|
+
* which under FAR-723 DENIES an absent permission set before ever reaching this
|
|
48
|
+
* primitive — so absence never grants at the carrier level.
|
|
44
49
|
*
|
|
45
50
|
* FAITHFUL COPY of the canonical `permissionSatisfies` in
|
|
46
51
|
* `@farthershore/contracts` (`authz/verbs.ts`); the published bundle is
|
|
@@ -51,13 +56,26 @@ export declare function permissionSatisfies(required: string, granted: readonly
|
|
|
51
56
|
/** The subset of a verified context these helpers read. */
|
|
52
57
|
export interface PermissionCarrier {
|
|
53
58
|
permissions?: readonly string[];
|
|
59
|
+
/** The verified X-Fs-Context claims, when the request carried a valid token. */
|
|
60
|
+
signedContext?: FartherShoreSignedContext;
|
|
54
61
|
}
|
|
55
62
|
/**
|
|
56
63
|
* True when the acting user holds `key`. Call only with a verified request
|
|
57
64
|
* context ({@link parsePermissionHeader} output lives on `context.permissions`).
|
|
58
65
|
* NOTE: this is a convenience for in-handler gating; the edge `permission`
|
|
59
|
-
* constraint is the security boundary for route-level access.
|
|
60
|
-
*
|
|
66
|
+
* constraint is the security boundary for route-level access.
|
|
67
|
+
*
|
|
68
|
+
* FAR-723 FAIL-CLOSED: an ABSENT permission set (`ctx.permissions === undefined`)
|
|
69
|
+
* DENIES — the reverse of the former grant-all grace — UNLESS the request
|
|
70
|
+
* carried a cryptographically VERIFIED context token that simply omits the
|
|
71
|
+
* `perms` claim. Core deliberately mints ORG-actor (and unidentified-user-actor)
|
|
72
|
+
* `fsc_` tokens with no `perms` claim — RBAC never applies to them, and the edge
|
|
73
|
+
* `permission` constraint treats a verified claimless token as full access. The
|
|
74
|
+
* carrier mirrors the edge: verified-but-claimless ⇒ grant; no verified context
|
|
75
|
+
* AND no permission set ⇒ no trusted permission source ⇒ deny. A DEFINED array
|
|
76
|
+
* uses the unified {@link permissionSatisfies} rule (`*` / `<subject>:*` /
|
|
77
|
+
* exact), so an explicit `["*"]` (org OWNER / RBAC-disabled) still grants
|
|
78
|
+
* everything and `[]` denies.
|
|
61
79
|
*/
|
|
62
80
|
export declare function hasPermission(ctx: PermissionCarrier, key: string): boolean;
|
|
63
81
|
/**
|
|
@@ -39,6 +39,38 @@ export type FartherShoreInitOptions = {
|
|
|
39
39
|
tunnel?: FartherShoreTunnelOptions;
|
|
40
40
|
/** SDK metadata forwarded to bootstrap. */
|
|
41
41
|
instanceId?: string;
|
|
42
|
+
/**
|
|
43
|
+
* FAR-723 / UA-6 — HS256 secret(s) for verifying the gateway's SIGNED
|
|
44
|
+
* `X-Fs-Context` claim. These are the GATEWAY CONTEXT-SIGNING keyring values
|
|
45
|
+
* (`CONTEXT_SIGNING_KEYS_JSON` — the keys `forward-upstream` stamps the
|
|
46
|
+
* header with; supply every live key during rotation — try-all). They are
|
|
47
|
+
* NOT the product's `contextTokenSecret`, which signs `fsc_` INGRESS tokens
|
|
48
|
+
* verified BY the gateway — setting that here would reject every valid
|
|
49
|
+
* gateway request in `"required"` mode. When present, a VERIFIED context's
|
|
50
|
+
* `permissions`/`roles` claims are the AUTHORITATIVE identity source
|
|
51
|
+
* (preferred over the transitional unsigned `x-fs-permissions`/`x-fs-roles`
|
|
52
|
+
* headers). Defaults to `FS_CONTEXT_SECRETS` (comma-separated) from the env.
|
|
53
|
+
*
|
|
54
|
+
* NOTE (core-side dependency, FAR-723 publish gate): no bootstrap field
|
|
55
|
+
* carries these keys yet, and handing the raw platform keyring to builder
|
|
56
|
+
* backends is NOT the end-state (it would allow cross-product context
|
|
57
|
+
* forgery). The distribution mechanism — per-product derived keys or an
|
|
58
|
+
* asymmetric context signature verified via JWKS like the request
|
|
59
|
+
* signature — is decided at the FAR-723 gate before the headers retire;
|
|
60
|
+
* until then this option (or `FS_CONTEXT_SECRETS`) is the manual wiring for
|
|
61
|
+
* platform-operated deployments.
|
|
62
|
+
*/
|
|
63
|
+
contextSecrets?: readonly string[];
|
|
64
|
+
/**
|
|
65
|
+
* FAR-723 / UA-6 — `"preferred"` (default): a missing/unverifiable signed
|
|
66
|
+
* context falls back to the transitional unsigned headers.
|
|
67
|
+
* `"required"`: a missing or invalid signed context REJECTS the request
|
|
68
|
+
* (fail-closed), including when the context-secret keyring is empty or
|
|
69
|
+
* unconfigured. Defaults to `FS_CONTEXT_VERIFICATION` from the env, else
|
|
70
|
+
* `"preferred"`. The unsigned fallback is transitional and is removed after
|
|
71
|
+
* the backend-v* publish gate.
|
|
72
|
+
*/
|
|
73
|
+
contextVerification?: "preferred" | "required";
|
|
42
74
|
};
|
|
43
75
|
export declare const SDK_VERSION: string;
|
|
44
76
|
export declare const CONTRACTS_FP: string;
|
|
@@ -55,6 +87,10 @@ export declare class FartherShore {
|
|
|
55
87
|
private readonly coreUrl;
|
|
56
88
|
private readonly instanceId?;
|
|
57
89
|
private readonly tunnelOptions;
|
|
90
|
+
/** FAR-723 — HS256 secret(s) for verifying the signed X-Fs-Context claim. */
|
|
91
|
+
private readonly contextSecrets;
|
|
92
|
+
/** FAR-723 — "preferred" (fallback to unsigned) | "required" (fail-closed). */
|
|
93
|
+
private readonly contextVerification;
|
|
58
94
|
private readonly nonceCache;
|
|
59
95
|
private readonly shutdownManager;
|
|
60
96
|
private jwks;
|
|
@@ -34,11 +34,12 @@ export type FartherShoreRequestContext = {
|
|
|
34
34
|
meters?: string[];
|
|
35
35
|
features?: Record<string, unknown>;
|
|
36
36
|
/**
|
|
37
|
-
* Managed-RBAC permissions the gateway resolved for the acting user
|
|
38
|
-
* the
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
37
|
+
* Managed-RBAC permissions the gateway resolved for the acting user —
|
|
38
|
+
* preferred from the VERIFIED signed `X-Fs-Context` claim, else the UNSIGNED
|
|
39
|
+
* `x-fs-permissions` identity header (both trusted transitively on a verified
|
|
40
|
+
* request — see permissions.ts). `undefined` when NO permission source was
|
|
41
|
+
* present ⇒ FAR-723 carrier-level DENY; `[]` for an authenticated user with
|
|
42
|
+
* no grants. Read via {@link hasPermission} / {@link requirePermission}.
|
|
42
43
|
*/
|
|
43
44
|
permissions?: string[];
|
|
44
45
|
/** UA-6 — the VERIFIED signed context payload, when context secrets are
|
package/dist/types/index.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export { createExpressMiddleware, type ExpressMiddleware, type ExpressRequestLik
|
|
|
19
19
|
export { FS_RUNTIME_TOKEN_ENV, RUNTIME_TOKEN_PREFIXES, RUNTIME_TOKEN_CAPABILITIES, RUNTIME_HEADER_NAMES, RUNTIME_CLOCK_SKEW_SECONDS, RUNTIME_REPLAY_WINDOW_SECONDS, EMPTY_BODY_SHA256, STREAMING_EXEMPT_BODY_HASH, MAX_BODY_BYTES, type RuntimeErrorCode, type RuntimeTokenCapability, type CanonicalSigningInput, type RuntimeBootstrapResponse, type RuntimeMeteringEvent, type RuntimeHealthReport, type TransportMode, RUNTIME_ERROR_CODE_TO_ERROR_CODE, runtimeErrorToErrorCode, type LimitDescriptor, type RuntimeMappedErrorCode, } from "./runtime-types.js";
|
|
20
20
|
export { RUNTIME_ERROR_CODES } from "./generated/runtime-contract.js";
|
|
21
21
|
export { hashBody, buildCanonicalSigningString, canonicalizeQuery, signCanonicalString, verifyCanonicalSignature, runtimeTokenKind, } from "./runtime-signing.js";
|
|
22
|
-
export { createUsage, withUsage, MeteringError, METERING_PAYLOAD_HEADER, METERING_SIGNATURE_HEADER, METERING_TOKEN_HEADER, DEFAULT_TOKEN_ENV, type UsageMap, type UsageReporter, type MeteringOptions, } from "./response-metering.js";
|
|
22
|
+
export { createUsage, withUsage, computeMeteringHeaders, MeteringError, METERING_PAYLOAD_HEADER, METERING_SIGNATURE_HEADER, METERING_TOKEN_HEADER, DEFAULT_TOKEN_ENV, type UsageMap, type UsageReporter, type MeteringOptions, type MeteringHeaders, type ComputeMeteringOptions, type ResponseMeteringUsagePayload, } from "./response-metering.js";
|
|
23
23
|
/**
|
|
24
24
|
* The conceptual public entrypoint. `fartherShore.initFromEnv()` mirrors the
|
|
25
25
|
* language-neutral spec. The returned instance is augmented with `middleware()`
|
|
@@ -4,6 +4,41 @@ declare const RESPONSE_METERING_ERROR_CODES: {
|
|
|
4
4
|
readonly invalidMeterValue: "invalid_meter_value";
|
|
5
5
|
};
|
|
6
6
|
type ResponseMeteringErrorCode = (typeof RESPONSE_METERING_ERROR_CODES)[keyof typeof RESPONSE_METERING_ERROR_CODES];
|
|
7
|
+
/**
|
|
8
|
+
* The signed response-metering payload. `computeMeteringHeaders` accepts one of
|
|
9
|
+
* these directly, so a non-Express / non-Fetch handler (or a Python/Go backend
|
|
10
|
+
* following the wire recipe) can stamp valid headers without `withUsage`.
|
|
11
|
+
*/
|
|
12
|
+
export type ResponseMeteringUsagePayload = {
|
|
13
|
+
method: string;
|
|
14
|
+
path: string;
|
|
15
|
+
rawDimsUnits: Record<string, number>;
|
|
16
|
+
measureContext?: Record<string, unknown>;
|
|
17
|
+
creditUnitsConsumed?: Record<string, number>;
|
|
18
|
+
operationKey?: string;
|
|
19
|
+
usagePolicyId?: string;
|
|
20
|
+
};
|
|
21
|
+
/** The three response-metering headers as a plain, attachable name→value map. */
|
|
22
|
+
export type MeteringHeaders = Record<string, string>;
|
|
23
|
+
export type ComputeMeteringOptions = {
|
|
24
|
+
token?: string;
|
|
25
|
+
env?: Record<string, string | undefined>;
|
|
26
|
+
/** Gateway request id, for dev-mode trace/usage association (never signed). */
|
|
27
|
+
requestId?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Called (instead of throwing) when headers cannot be stamped at request time
|
|
30
|
+
* — e.g. no token. The endpoint is never broken; usage is simply not metered.
|
|
31
|
+
*/
|
|
32
|
+
onSkip?: (reason: string) => void;
|
|
33
|
+
};
|
|
34
|
+
export type DevMeteringHooks = {
|
|
35
|
+
/** Ephemeral token used when no real token is configured (dev only). */
|
|
36
|
+
fallbackToken?: () => string | undefined;
|
|
37
|
+
/** Record a reported payload (never alters the response). */
|
|
38
|
+
record?: (payload: ResponseMeteringUsagePayload, requestId?: string) => void;
|
|
39
|
+
/** Observe a request-time skip ("metering headers skipped: <reason>"). */
|
|
40
|
+
onSkip?: (reason: string, requestId?: string) => void;
|
|
41
|
+
};
|
|
7
42
|
export declare const METERING_PAYLOAD_HEADER: "x-fs-metering";
|
|
8
43
|
export declare const METERING_SIGNATURE_HEADER: "x-fs-metering-sig";
|
|
9
44
|
export declare const METERING_TOKEN_HEADER: "x-fs-metering-token";
|
|
@@ -38,4 +73,14 @@ export declare class MeteringError extends Error {
|
|
|
38
73
|
}
|
|
39
74
|
export declare function createUsage(request: Request, options?: MeteringOptions): UsageReporter;
|
|
40
75
|
export declare function withUsage(request: Request, response: Response, usage: UsageMap, options?: MeteringOptions): Promise<Response>;
|
|
76
|
+
/**
|
|
77
|
+
* Compute the three response-metering headers for a payload as a plain
|
|
78
|
+
* name→value map, attachable to ANY response mechanism (Fetch `Response`,
|
|
79
|
+
* Express `res.set`, a raw header object). This is the metering-availability
|
|
80
|
+
* primitive: it NEVER throws at request time — if no token is resolvable (or
|
|
81
|
+
* signing fails) it skips stamping, reports the reason (`onSkip` / dev hook /
|
|
82
|
+
* a `console.warn`), and returns `{}` so the builder's endpoint is never broken.
|
|
83
|
+
* `withUsage`/`createUsage` are thin sugar over it.
|
|
84
|
+
*/
|
|
85
|
+
export declare function computeMeteringHeaders(payload: ResponseMeteringUsagePayload, options?: ComputeMeteringOptions): Promise<MeteringHeaders>;
|
|
41
86
|
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type RuntimeBootstrapResponse, type RuntimeMeteringEvent } from "../runtime-types.js";
|
|
2
|
+
import type { DevMode } from "./traceSink.js";
|
|
3
|
+
import type { DevSignerKeys } from "./signers.js";
|
|
4
|
+
export declare const DEV_CORE_URL = "https://dev-gateway.farthershore.local";
|
|
5
|
+
export declare const DEV_JWKS_URL = "https://dev-gateway.farthershore.local/.well-known/jwks.json";
|
|
6
|
+
export declare const DEV_METERING_ENDPOINT = "https://dev-gateway.farthershore.local/v1/metering/events";
|
|
7
|
+
export type DevGatewayOptions = {
|
|
8
|
+
mode: DevMode;
|
|
9
|
+
keys: DevSignerKeys;
|
|
10
|
+
productId?: string;
|
|
11
|
+
backendId?: string;
|
|
12
|
+
productSlug?: string;
|
|
13
|
+
backendSlug?: string;
|
|
14
|
+
/** Extra route ids to expose in bootstrap for route-binding tests. */
|
|
15
|
+
routeIds?: string[];
|
|
16
|
+
/** Called for each captured metering event (at-least-once ACK). */
|
|
17
|
+
onMeterEvent?: (event: RuntimeMeteringEvent) => void;
|
|
18
|
+
};
|
|
19
|
+
export type DevGateway = {
|
|
20
|
+
/** Inject this as `fetchImpl` when constructing the FartherShore runtime. */
|
|
21
|
+
fetchImpl: typeof fetch;
|
|
22
|
+
/** The bootstrap response this fixture serves. */
|
|
23
|
+
bootstrap: RuntimeBootstrapResponse;
|
|
24
|
+
/** Every captured background metering event (a capture == an ACK). */
|
|
25
|
+
meterEvents: RuntimeMeteringEvent[];
|
|
26
|
+
productId: string;
|
|
27
|
+
backendId: string;
|
|
28
|
+
jwksUrl: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Build the in-process dev gateway. `mode` drives `verification.required`:
|
|
32
|
+
* `passthrough` → false (the adapter passes requests through unverified, matching
|
|
33
|
+
* the pre-keystone deploy order), `simulated` → true (fail-closed verification is
|
|
34
|
+
* exercised end-to-end).
|
|
35
|
+
*/
|
|
36
|
+
export declare function createDevGateway(options: DevGatewayOptions): DevGateway;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { FartherShore } from "../core/runtime.js";
|
|
2
|
+
import { type ExpressMiddleware, type MiddlewareOptions } from "../adapters/express.js";
|
|
3
|
+
import { type PermissionCarrier } from "../core/permissions.js";
|
|
4
|
+
import { type DevGateway } from "./devGateway.js";
|
|
5
|
+
import { DevUsageSink } from "./usageSink.js";
|
|
6
|
+
import { DevTraceSink, type DevMode } from "./traceSink.js";
|
|
7
|
+
import { type DevSignerKeys } from "./signers.js";
|
|
8
|
+
import { type PersonaDefinition, type PersonaRequest } from "./personas.js";
|
|
9
|
+
/** A permission carrier that also knows its request id (for trace keying). */
|
|
10
|
+
export type TracedCarrier = PermissionCarrier & {
|
|
11
|
+
requestId?: string;
|
|
12
|
+
};
|
|
13
|
+
/** The FartherShore instance augmented with a bound Express middleware. */
|
|
14
|
+
export type FartherShoreDevInstance = FartherShore & {
|
|
15
|
+
middleware(options?: MiddlewareOptions): ExpressMiddleware;
|
|
16
|
+
/** The dev harness attached to this runtime. */
|
|
17
|
+
dev: DevRuntime;
|
|
18
|
+
};
|
|
19
|
+
export type CreateDevRuntimeOptions = {
|
|
20
|
+
mode: DevMode;
|
|
21
|
+
/** Persona overrides merged over the built-in owner/admin/member/anonymous. */
|
|
22
|
+
personas?: Record<string, PersonaDefinition> | PersonaDefinition[];
|
|
23
|
+
/** Route ids to expose in bootstrap (for route-binding tests). */
|
|
24
|
+
routes?: string[];
|
|
25
|
+
/** Meter keys (informational — the dev gateway accepts any meter). */
|
|
26
|
+
meters?: string[];
|
|
27
|
+
productId?: string;
|
|
28
|
+
backendId?: string;
|
|
29
|
+
/** Optional app transport for persona `.fetch()`; defaults to global fetch. */
|
|
30
|
+
appFetch?: typeof fetch;
|
|
31
|
+
/** Reuse a fixed key set (cross-process). Defaults to a fresh ephemeral set. */
|
|
32
|
+
keys?: DevSignerKeys;
|
|
33
|
+
};
|
|
34
|
+
/** The dev harness returned by `createDevRuntime` and attached as `fs.dev`. */
|
|
35
|
+
export type DevRuntime = {
|
|
36
|
+
fs: FartherShoreDevInstance;
|
|
37
|
+
asPersona(name: string): PersonaRequest;
|
|
38
|
+
usage: DevUsageSink;
|
|
39
|
+
trace: DevTraceSink;
|
|
40
|
+
gateway: DevGateway;
|
|
41
|
+
keys: DevSignerKeys;
|
|
42
|
+
personas: Map<string, PersonaDefinition>;
|
|
43
|
+
mode: DevMode;
|
|
44
|
+
bootstrap: DevGateway["bootstrap"];
|
|
45
|
+
/** Traced authz helpers — delegate to the REAL permission functions and
|
|
46
|
+
* record each decision into the trace (keyed by ctx.requestId). */
|
|
47
|
+
authz: {
|
|
48
|
+
hasPermission(ctx: TracedCarrier, key: string): boolean;
|
|
49
|
+
requirePermission(ctx: TracedCarrier, key: string): void;
|
|
50
|
+
};
|
|
51
|
+
/** A trace-aware Express middleware (wraps the real fail-closed middleware). */
|
|
52
|
+
middleware(options?: MiddlewareOptions): ExpressMiddleware;
|
|
53
|
+
/** Clear usage + trace + captured meter events. */
|
|
54
|
+
reset(): void;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Build a dev runtime: a real `FartherShore` wired to the in-process gateway and
|
|
58
|
+
* signed personas, plus assertable usage/trace side channels.
|
|
59
|
+
*/
|
|
60
|
+
export declare function createDevRuntime(options: CreateDevRuntimeOptions): DevRuntime;
|
|
61
|
+
/** Whether `FS_DEV_MODE` selects a dev mode. Returns the mode or null. */
|
|
62
|
+
export declare function devModeFromEnv(env: Record<string, string | undefined>): DevMode | null;
|
|
63
|
+
/**
|
|
64
|
+
* Self-construct the dev simulator from the environment. Called by `initFromEnv`
|
|
65
|
+
* when `FS_DEV_MODE` is set. Prints a LOUD banner, wires JSONL usage/trace sinks
|
|
66
|
+
* + a mode-600 dev-keys file, and returns the augmented runtime.
|
|
67
|
+
*/
|
|
68
|
+
export declare function createDevRuntimeFromEnv(env?: Record<string, string | undefined>): FartherShoreDevInstance;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { TEST_KID, TEST_PRIVATE_JWK, TEST_PUBLIC_JWK, TEST_CONTEXT_SECRET, TEST_CONTEXT_KID, makeSignedRequest, memoryJwks, unreachableJwks, signContextToken, generateDevSignerKeys, type SignedHeaderOverrides, type SignedRequestSpec, type DevSignerKeys, } from "./signers.js";
|
|
2
|
+
export { definePersona, createPersonaClient, buildPersonaMap, DEFAULT_PERSONAS, CONTEXT_HEADER_NAME, SIGNED_HEADER_NAMES, type PersonaDefinition, type PersonaRequest, type PersonaRequestSpec, type PersonaClientContext, type SettableRequest, } from "./personas.js";
|
|
3
|
+
export { createDevGateway, DEV_CORE_URL, DEV_JWKS_URL, DEV_METERING_ENDPOINT, type DevGateway, type DevGatewayOptions, } from "./devGateway.js";
|
|
4
|
+
export { createDevRuntime, createDevRuntimeFromEnv, devModeFromEnv, type DevRuntime, type CreateDevRuntimeOptions, type FartherShoreDevInstance, type TracedCarrier, } from "./devRuntime.js";
|
|
5
|
+
export { writeDevKeysFile, readDevKeysFile, personaClientFromKeysFile, DEFAULT_KEYS_FILE, type DevKeysFile, } from "./keysFile.js";
|
|
6
|
+
export { DevUsageSink, type DevUsageEvent } from "./usageSink.js";
|
|
7
|
+
export { DevTraceSink, redactValue, type DevTrace, type DevMode, type VerificationOutcome, type AuthzDecisionEntry, type MeteringTraceEntry, } from "./traceSink.js";
|
|
8
|
+
export { isProductionEnv, assertNotProduction, DevModeInProductionError, } from "./prodGuard.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DevMode } from "./traceSink.js";
|
|
2
|
+
import type { DevSignerKeys } from "./signers.js";
|
|
3
|
+
import { type PersonaDefinition, type PersonaRequest } from "./personas.js";
|
|
4
|
+
export declare const DEFAULT_KEYS_FILE = ".farthershore/dev-keys.json";
|
|
5
|
+
/** The on-disk shape of the dev-keys handoff file. */
|
|
6
|
+
export type DevKeysFile = {
|
|
7
|
+
version: 1;
|
|
8
|
+
mode: DevMode;
|
|
9
|
+
keys: DevSignerKeys;
|
|
10
|
+
productId: string;
|
|
11
|
+
backendId: string;
|
|
12
|
+
/** Persona definitions in effect for this dev session. */
|
|
13
|
+
personas: Record<string, PersonaDefinition>;
|
|
14
|
+
};
|
|
15
|
+
/** Write the dev-keys file with 0600 permissions (owner read/write only). */
|
|
16
|
+
export declare function writeDevKeysFile(path: string, contents: DevKeysFile): void;
|
|
17
|
+
/** Read + parse a dev-keys file. */
|
|
18
|
+
export declare function readDevKeysFile(path?: string): DevKeysFile;
|
|
19
|
+
/**
|
|
20
|
+
* Construct a persona client from a dev-keys file — the cross-process entry
|
|
21
|
+
* point. A test runner in a separate process reads the file the running service
|
|
22
|
+
* wrote and can immediately `asPersona("member").fetch(url)` against it.
|
|
23
|
+
*/
|
|
24
|
+
export declare function personaClientFromKeysFile(path?: string, options?: {
|
|
25
|
+
fetchImpl?: typeof fetch;
|
|
26
|
+
}): {
|
|
27
|
+
asPersona(name: string): PersonaRequest;
|
|
28
|
+
personas: Map<string, PersonaDefinition>;
|
|
29
|
+
file: DevKeysFile;
|
|
30
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { DevMode } from "./traceSink.js";
|
|
2
|
+
import { type DevSignerKeys } from "./signers.js";
|
|
3
|
+
/** A named identity in the platform's test-persona claim vocabulary. */
|
|
4
|
+
export type PersonaDefinition = {
|
|
5
|
+
name: string;
|
|
6
|
+
orgId?: string;
|
|
7
|
+
actor?: {
|
|
8
|
+
type: string;
|
|
9
|
+
id: string | null;
|
|
10
|
+
};
|
|
11
|
+
productId?: string;
|
|
12
|
+
environmentId?: string | null;
|
|
13
|
+
compiledPlanId?: string;
|
|
14
|
+
subscriptionId?: string;
|
|
15
|
+
subscriberId?: string;
|
|
16
|
+
/**
|
|
17
|
+
* The unified-authz permission grant. `["*"]` = full access (org OWNER /
|
|
18
|
+
* RBAC-disabled). `[]` = authenticated with no grants (fail-closed on every
|
|
19
|
+
* check). Omit to mint a verified-but-claimless context (RBAC N/A ⇒ grant).
|
|
20
|
+
*/
|
|
21
|
+
permissions?: string[];
|
|
22
|
+
roles?: string[];
|
|
23
|
+
subjectKey?: string;
|
|
24
|
+
/**
|
|
25
|
+
* When true, `asPersona` emits NO `X-Fs-Context` — an unidentified caller.
|
|
26
|
+
* Under `simulated` mode the request still verifies (valid signature) but
|
|
27
|
+
* carrier-level permission checks fail closed. Used by the `anonymous` default.
|
|
28
|
+
*/
|
|
29
|
+
anonymous?: boolean;
|
|
30
|
+
};
|
|
31
|
+
/** Identity-neutral pass-through of a persona definition (readability sugar). */
|
|
32
|
+
export declare function definePersona(def: PersonaDefinition): PersonaDefinition;
|
|
33
|
+
/** The four built-in personas: owner / admin / member / anonymous. */
|
|
34
|
+
export declare const DEFAULT_PERSONAS: Record<string, PersonaDefinition>;
|
|
35
|
+
/** A minimal `.set(name, value)`-carrying request (supertest `Test`, etc.). */
|
|
36
|
+
export type SettableRequest = {
|
|
37
|
+
method?: string;
|
|
38
|
+
url?: string;
|
|
39
|
+
path?: string;
|
|
40
|
+
set(field: string, value: string): unknown;
|
|
41
|
+
};
|
|
42
|
+
/** A single request spec for `.headers()`. */
|
|
43
|
+
export type PersonaRequestSpec = {
|
|
44
|
+
method?: string;
|
|
45
|
+
path?: string;
|
|
46
|
+
query?: string;
|
|
47
|
+
body?: Uint8Array | null;
|
|
48
|
+
streamingExempt?: boolean;
|
|
49
|
+
routeId?: string;
|
|
50
|
+
requestId?: string;
|
|
51
|
+
timestamp?: number;
|
|
52
|
+
};
|
|
53
|
+
/** The context a persona client signs against (bootstrap ids + dev keys). */
|
|
54
|
+
export type PersonaClientContext = {
|
|
55
|
+
keys: DevSignerKeys;
|
|
56
|
+
productId: string;
|
|
57
|
+
backendId: string;
|
|
58
|
+
contextSecret: string;
|
|
59
|
+
contextKid: string;
|
|
60
|
+
personas: Map<string, PersonaDefinition>;
|
|
61
|
+
mode: DevMode;
|
|
62
|
+
/** fetch used by `.fetch()`; defaults to global fetch for real app listeners. */
|
|
63
|
+
fetchImpl?: typeof fetch;
|
|
64
|
+
};
|
|
65
|
+
/** The request-builder returned by `asPersona(name)`. */
|
|
66
|
+
export type PersonaRequest = {
|
|
67
|
+
/** The persona name. */
|
|
68
|
+
readonly persona: string;
|
|
69
|
+
/**
|
|
70
|
+
* The 9 signed `x-fs-*` headers + `X-Fs-Context` (unless anonymous).
|
|
71
|
+
* Defaults to GET; pass `method` for non-GET requests so the signature
|
|
72
|
+
* matches the request the caller sends.
|
|
73
|
+
*/
|
|
74
|
+
headers(spec?: PersonaRequestSpec): Promise<Record<string, string>>;
|
|
75
|
+
/** Real HTTP with signed headers (uses the wired fetch). */
|
|
76
|
+
fetch(url: string, init?: RequestInit): Promise<Response>;
|
|
77
|
+
/** Set the signed headers on any `.set()`-carrying request (supertest, etc.). */
|
|
78
|
+
inject(req: SettableRequest, spec?: PersonaRequestSpec): Promise<SettableRequest>;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Build a persona client bound to a set of dev keys + bootstrap ids. Returns an
|
|
82
|
+
* `asPersona(name)` factory. Constructed by the dev runtime; also constructible
|
|
83
|
+
* standalone from a dev-keys file (see `personaClientFromKeysFile`).
|
|
84
|
+
*/
|
|
85
|
+
export declare function createPersonaClient(ctx: PersonaClientContext): {
|
|
86
|
+
asPersona(name: string): PersonaRequest;
|
|
87
|
+
personas: Map<string, PersonaDefinition>;
|
|
88
|
+
};
|
|
89
|
+
/** Merge caller personas over the built-in defaults into a name→def map. */
|
|
90
|
+
export declare function buildPersonaMap(overrides?: Record<string, PersonaDefinition> | PersonaDefinition[]): Map<string, PersonaDefinition>;
|
|
91
|
+
/** The header name for the signed context (exported for assertions). */
|
|
92
|
+
export declare const CONTEXT_HEADER_NAME = "x-fs-context";
|
|
93
|
+
/** The signed request header names (exported for assertions). */
|
|
94
|
+
export declare const SIGNED_HEADER_NAMES: {
|
|
95
|
+
readonly signature: "x-fs-signature";
|
|
96
|
+
readonly keyId: "x-fs-key-id";
|
|
97
|
+
readonly requestId: "x-fs-request-id";
|
|
98
|
+
readonly timestamp: "x-fs-timestamp";
|
|
99
|
+
readonly productId: "x-fs-product-id";
|
|
100
|
+
readonly backendId: "x-fs-backend-id";
|
|
101
|
+
readonly routeId: "x-fs-route-id";
|
|
102
|
+
readonly policyVersion: "x-fs-policy-version";
|
|
103
|
+
readonly bodyHash: "x-fs-body-hash";
|
|
104
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Case-insensitive `NODE_ENV=production` check (matches core's `isProductionEnv`). */
|
|
2
|
+
export declare function isProductionEnv(env?: Record<string, string | undefined>): boolean;
|
|
3
|
+
/** Thrown when a dev runtime is constructed in a production process. */
|
|
4
|
+
export declare class DevModeInProductionError extends Error {
|
|
5
|
+
constructor(context: string);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Throw {@link DevModeInProductionError} when `NODE_ENV=production`. Call at the
|
|
9
|
+
* TOP of every dev-runtime constructor. `context` names the entry point for the
|
|
10
|
+
* error message (e.g. "createDevRuntime", "FS_DEV_MODE").
|
|
11
|
+
*/
|
|
12
|
+
export declare function assertNotProduction(context: string, env?: Record<string, string | undefined>): void;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type CanonicalSigningInput } from "../runtime-types.js";
|
|
2
|
+
import { JwksClient } from "../core/jwks.js";
|
|
3
|
+
import type { FartherShoreSignedContext } from "../core/verifyContext.js";
|
|
4
|
+
export declare const TEST_KID = "fs-runtime-test-2026";
|
|
5
|
+
export declare const TEST_PRIVATE_JWK: JsonWebKey;
|
|
6
|
+
export declare const TEST_PUBLIC_JWK: JsonWebKey;
|
|
7
|
+
/** The default HS256 context-signing secret for dev fixtures (NEVER production). */
|
|
8
|
+
export declare const TEST_CONTEXT_SECRET = "fs-dev-context-secret-2026";
|
|
9
|
+
/** The default context-signing kid stamped into the JWT header. */
|
|
10
|
+
export declare const TEST_CONTEXT_KID = "fs-context-test-2026";
|
|
11
|
+
export type SignedHeaderOverrides = Partial<{
|
|
12
|
+
signature: string;
|
|
13
|
+
kid: string;
|
|
14
|
+
requestId: string;
|
|
15
|
+
timestamp: number;
|
|
16
|
+
productId: string;
|
|
17
|
+
backendId: string;
|
|
18
|
+
routeId: string;
|
|
19
|
+
policyVersion: string;
|
|
20
|
+
bodyHash: string;
|
|
21
|
+
}>;
|
|
22
|
+
export type SignedRequestSpec = {
|
|
23
|
+
method?: string;
|
|
24
|
+
path?: string;
|
|
25
|
+
query?: string;
|
|
26
|
+
body?: Uint8Array | null;
|
|
27
|
+
streamingExempt?: boolean;
|
|
28
|
+
productId?: string;
|
|
29
|
+
backendId?: string;
|
|
30
|
+
routeId?: string;
|
|
31
|
+
policyVersion?: string;
|
|
32
|
+
requestId?: string;
|
|
33
|
+
timestamp?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Ed25519 private JWK to sign with. Defaults to {@link TEST_PRIVATE_JWK}. A
|
|
36
|
+
* dev simulator passes its ephemeral signer here.
|
|
37
|
+
*/
|
|
38
|
+
privateJwk?: JsonWebKey;
|
|
39
|
+
/** Key id stamped into `x-fs-key-id`. Defaults to {@link TEST_KID}. */
|
|
40
|
+
kid?: string;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Produce a valid signed claim + the X-FS-* header bag. Returns both the verify
|
|
44
|
+
* input shape and a mutable header record you can corrupt for negative tests.
|
|
45
|
+
*/
|
|
46
|
+
export declare function makeSignedRequest(spec?: SignedRequestSpec): Promise<{
|
|
47
|
+
input: {
|
|
48
|
+
method: string;
|
|
49
|
+
path: string;
|
|
50
|
+
query: string;
|
|
51
|
+
body: Uint8Array | null;
|
|
52
|
+
streamingExempt: boolean;
|
|
53
|
+
};
|
|
54
|
+
claim: CanonicalSigningInput;
|
|
55
|
+
headers: Record<string, string>;
|
|
56
|
+
}>;
|
|
57
|
+
/** A JwksClient backed by an in-memory key set (no network). */
|
|
58
|
+
export declare function memoryJwks(keys?: Array<JsonWebKey & {
|
|
59
|
+
kid?: string;
|
|
60
|
+
}>): JwksClient;
|
|
61
|
+
/** A JwksClient whose fetch always throws (cold-cache failure). */
|
|
62
|
+
export declare function unreachableJwks(): JwksClient;
|
|
63
|
+
/**
|
|
64
|
+
* Mint an HS256-signed `X-Fs-Context` token — the exact INVERSE of
|
|
65
|
+
* `verifyContext.ts`. The header is `{alg:"HS256",typ:"JWT",kid}` and the
|
|
66
|
+
* payload is the `cv:1` claim shape (orgId / actor / productId / compiledPlanId
|
|
67
|
+
* / subscriptionId / subscriberId / environmentId / subjectKey plus optional
|
|
68
|
+
* permissions / roles). Signs with the same secret the SDK would verify against
|
|
69
|
+
* (`contextSecrets` / `FS_CONTEXT_SECRETS`).
|
|
70
|
+
*/
|
|
71
|
+
export declare function signContextToken(claim: FartherShoreSignedContext, secret?: string, kid?: string): Promise<string>;
|
|
72
|
+
export type DevSignerKeys = {
|
|
73
|
+
/** Ed25519 signing keypair as JWKs (request-signature keys). */
|
|
74
|
+
kid: string;
|
|
75
|
+
privateJwk: JsonWebKey;
|
|
76
|
+
publicJwk: JsonWebKey;
|
|
77
|
+
/** HS256 context-signing secret + its kid (X-Fs-Context keys). */
|
|
78
|
+
contextKid: string;
|
|
79
|
+
contextSecret: string;
|
|
80
|
+
/** Ephemeral fsrt_test_ runtime token (bootstrap bearer + metering HMAC). */
|
|
81
|
+
runtimeToken: string;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Generate a fresh, ephemeral set of dev signer keys: an Ed25519 request-signing
|
|
85
|
+
* keypair, an HS256 context secret, and a `fsrt_test_` runtime token. Fully
|
|
86
|
+
* synchronous (uses `node:crypto`) so a dev simulator can be built without
|
|
87
|
+
* awaiting. NEVER call this in production (guarded upstream).
|
|
88
|
+
*/
|
|
89
|
+
export declare function generateDevSignerKeys(): DevSignerKeys;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export type DevMode = "passthrough" | "simulated";
|
|
2
|
+
export type VerificationOutcome = "verified" | "passthrough" | "rejected";
|
|
3
|
+
export type AuthzDecisionEntry = {
|
|
4
|
+
permission: string;
|
|
5
|
+
decision: "allow" | "deny";
|
|
6
|
+
reason?: string;
|
|
7
|
+
};
|
|
8
|
+
export type MeteringTraceEntry = {
|
|
9
|
+
meters: Record<string, number>;
|
|
10
|
+
source: "response" | "meter";
|
|
11
|
+
};
|
|
12
|
+
export type DevTrace = {
|
|
13
|
+
requestId: string;
|
|
14
|
+
method?: string;
|
|
15
|
+
path?: string;
|
|
16
|
+
persona?: string;
|
|
17
|
+
mode: DevMode;
|
|
18
|
+
verification?: {
|
|
19
|
+
outcome: VerificationOutcome;
|
|
20
|
+
reason?: string;
|
|
21
|
+
};
|
|
22
|
+
authz: AuthzDecisionEntry[];
|
|
23
|
+
metering: MeteringTraceEntry[];
|
|
24
|
+
response?: {
|
|
25
|
+
status: number;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/** Redact anything that smells like a credential from a free-form string. */
|
|
29
|
+
export declare function redactValue(input: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Accumulates one `DevTrace` per request id and (optionally) appends each
|
|
32
|
+
* completed trace as a single JSONL line via the injected `appendLine` sink.
|
|
33
|
+
*/
|
|
34
|
+
export declare class DevTraceSink {
|
|
35
|
+
private readonly traces;
|
|
36
|
+
private readonly flushed;
|
|
37
|
+
private readonly appendLine?;
|
|
38
|
+
constructor(options?: {
|
|
39
|
+
appendLine?: (line: string) => void;
|
|
40
|
+
});
|
|
41
|
+
private ensure;
|
|
42
|
+
/** Record how a request's signature/context verification resolved. */
|
|
43
|
+
recordVerification(requestId: string, mode: DevMode, outcome: VerificationOutcome, fields?: {
|
|
44
|
+
method?: string;
|
|
45
|
+
path?: string;
|
|
46
|
+
persona?: string;
|
|
47
|
+
reason?: string;
|
|
48
|
+
}): void;
|
|
49
|
+
/** Record a single hasPermission / requirePermission decision. */
|
|
50
|
+
recordAuthz(requestId: string, mode: DevMode, entry: AuthzDecisionEntry): void;
|
|
51
|
+
/** Record usage reported for this request. */
|
|
52
|
+
recordMetering(requestId: string, mode: DevMode, entry: MeteringTraceEntry): void;
|
|
53
|
+
/** Record the final response status and flush the trace as one JSONL line. */
|
|
54
|
+
recordResponse(requestId: string, mode: DevMode, status: number): void;
|
|
55
|
+
/**
|
|
56
|
+
* Append a trace's current state as one JSONL line (if a sink is wired).
|
|
57
|
+
* Flushes at most ONCE per request id, so wrapping several response methods
|
|
58
|
+
* (status/json/end) never produces duplicate JSONL lines.
|
|
59
|
+
*/
|
|
60
|
+
flush(requestId: string): void;
|
|
61
|
+
/** The accumulated trace for a request id, or `undefined`. */
|
|
62
|
+
forRequest(requestId: string): DevTrace | undefined;
|
|
63
|
+
/** Every accumulated trace (insertion order). */
|
|
64
|
+
all(): DevTrace[];
|
|
65
|
+
/** Clear all traces. */
|
|
66
|
+
reset(): void;
|
|
67
|
+
}
|