@somewhatintelligent/kit 0.0.1-beta.1783705886.297c5be
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/package.json +58 -0
- package/src/execution-context/index.ts +16 -0
- package/src/ids/__tests__/index.test.ts +80 -0
- package/src/ids/index.ts +60 -0
- package/src/log/__tests__/http.test.ts +109 -0
- package/src/log/__tests__/index.test.ts +254 -0
- package/src/log/__tests__/instrumented.test.ts +319 -0
- package/src/log/__tests__/scheduled.test.ts +107 -0
- package/src/log/core.ts +230 -0
- package/src/log/http.ts +72 -0
- package/src/log/index.ts +22 -0
- package/src/log/instrumented.ts +191 -0
- package/src/log/scheduled.ts +84 -0
- package/src/react/auth.tsx +63 -0
- package/src/react/index.ts +2 -0
- package/src/react-start/__tests__/dev-envelope.test.ts +153 -0
- package/src/react-start/__tests__/smoke.test.ts +77 -0
- package/src/react-start/auth-provider.tsx +81 -0
- package/src/react-start/client.ts +11 -0
- package/src/react-start/dev-envelope.ts +136 -0
- package/src/react-start/envelope-middleware.ts +135 -0
- package/src/react-start/index.ts +29 -0
- package/src/react-start/logging.ts +70 -0
- package/src/react-start/platform-start-app.ts +382 -0
- package/src/react-start/platform-start-context.ts +25 -0
- package/src/react-start/request-logger.ts +60 -0
- package/src/react-start/service-clients.ts +246 -0
- package/src/request-context/__tests__/index.test.ts +90 -0
- package/src/request-context/index.ts +189 -0
- package/src/roles.ts +25 -0
- package/src/version/__tests__/index.test.ts +140 -0
- package/src/version/index.ts +85 -0
- package/src/version.gen.ts +7 -0
package/src/log/http.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-edge wrapper. Use inside framework-specific request middleware
|
|
3
|
+
* (TanStack Start `createMiddleware({type: "request"})`, Elysia `onRequest`/
|
|
4
|
+
* `onAfterHandle`, raw `fetch()` handlers) to emit one `event: "http"` line
|
|
5
|
+
* per inbound request — captures method/path automatically, lets the
|
|
6
|
+
* middleware add status/userId/etc via the builder.
|
|
7
|
+
*
|
|
8
|
+
* The default operation name is `"http.<method-lowercased>"`; pass
|
|
9
|
+
* `deriveOperation` to use route-derived ops like `"roadie.api.upload"`
|
|
10
|
+
* when the framework knows the matched route.
|
|
11
|
+
*
|
|
12
|
+
* Usage (TanStack Start middleware):
|
|
13
|
+
*
|
|
14
|
+
* const middleware = createMiddleware({ type: "request" }).server(
|
|
15
|
+
* async ({ next, request }) =>
|
|
16
|
+
* withRequestLog(
|
|
17
|
+
* {
|
|
18
|
+
* service: "roadie",
|
|
19
|
+
* resolveContext: (req) => ({
|
|
20
|
+
* requestId: req.headers.get("cf-request-id") ?? ulid(),
|
|
21
|
+
* actorKind: "anonymous",
|
|
22
|
+
* actorId: null,
|
|
23
|
+
* }),
|
|
24
|
+
* },
|
|
25
|
+
* request,
|
|
26
|
+
* async (log) => {
|
|
27
|
+
* const result = await next();
|
|
28
|
+
* log.add({ status: result.response.status });
|
|
29
|
+
* if (result.response.status >= 500) log.outcome("internal_error");
|
|
30
|
+
* else if (result.response.status >= 400) log.outcome(`http_${result.response.status}`);
|
|
31
|
+
* return result;
|
|
32
|
+
* },
|
|
33
|
+
* ),
|
|
34
|
+
* );
|
|
35
|
+
*/
|
|
36
|
+
import { type CanonicalLogBuilder, type CanonicalLogContext, withCanonicalLog } from "./core";
|
|
37
|
+
|
|
38
|
+
type PartialContext = Omit<CanonicalLogContext, "service" | "event" | "operation">;
|
|
39
|
+
|
|
40
|
+
export interface RequestLogConfig {
|
|
41
|
+
service: string;
|
|
42
|
+
/**
|
|
43
|
+
* Optional. Provide explicit ctx fields when not relying on the
|
|
44
|
+
* request-context ALS. When omitted, `withCanonicalLog` reads
|
|
45
|
+
* requestId/actorKind/actorId/callerApp from ALS at emit time.
|
|
46
|
+
*/
|
|
47
|
+
resolveContext?: (req: Request) => Promise<PartialContext> | PartialContext;
|
|
48
|
+
/** Override op name. Default: `"http.<method-lowercased>"`. */
|
|
49
|
+
deriveOperation?: (req: Request) => string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function withRequestLog<T>(
|
|
53
|
+
config: RequestLogConfig,
|
|
54
|
+
req: Request,
|
|
55
|
+
fn: (log: CanonicalLogBuilder) => Promise<T>,
|
|
56
|
+
): Promise<T> {
|
|
57
|
+
const partial = (await config.resolveContext?.(req)) ?? {};
|
|
58
|
+
const operation = config.deriveOperation?.(req) ?? `http.${req.method.toLowerCase()}`;
|
|
59
|
+
return withCanonicalLog(
|
|
60
|
+
{
|
|
61
|
+
...partial,
|
|
62
|
+
service: config.service,
|
|
63
|
+
event: "http",
|
|
64
|
+
operation,
|
|
65
|
+
},
|
|
66
|
+
async (log) => {
|
|
67
|
+
const url = new URL(req.url);
|
|
68
|
+
log.add({ method: req.method, path: url.pathname });
|
|
69
|
+
return fn(log);
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
}
|
package/src/log/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical log barrel — re-exports the core primitive (`./core`) plus the
|
|
3
|
+
* boundary wrappers (`./http`, `./instrumented`, `./scheduled`).
|
|
4
|
+
*
|
|
5
|
+
* Sub-modules import from `./core` rather than `./index` to keep this barrel
|
|
6
|
+
* acyclic. Read `./core` for the architectural overview.
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
type CanonicalLogBuilder,
|
|
10
|
+
type CanonicalLogContext,
|
|
11
|
+
describeThrown,
|
|
12
|
+
getRequestLog,
|
|
13
|
+
type Level,
|
|
14
|
+
requireRequestLog,
|
|
15
|
+
withCanonicalLog,
|
|
16
|
+
} from "./core";
|
|
17
|
+
export { withRequestLog } from "./http";
|
|
18
|
+
export type { RequestLogConfig } from "./http";
|
|
19
|
+
export { instrumented, logged } from "./instrumented";
|
|
20
|
+
export type { InstrumentationConfig } from "./instrumented";
|
|
21
|
+
export { loggedJob } from "./scheduled";
|
|
22
|
+
export type { JobLogConfig } from "./scheduled";
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Class-level instrumentation for RPC service classes.
|
|
3
|
+
*
|
|
4
|
+
* Walks the class prototype at install time and wraps every async method
|
|
5
|
+
* with a `withCanonicalLog` scope. Method bodies stay as plain method
|
|
6
|
+
* definitions (required for Cloudflare Workers RPC dispatch — arrow-fn
|
|
7
|
+
* class fields aren't on the prototype, so CF can't see them as RPC methods).
|
|
8
|
+
* Domain fields are accrued in handler bodies via `requireRequestLog().add()`.
|
|
9
|
+
*
|
|
10
|
+
* `@instrumented({...})` — TC39 Stage 3 class decorator. Use when the
|
|
11
|
+
* bundler transforms decorator syntax (vite-plus apps, CF Agents apps).
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
*
|
|
15
|
+
* @instrumented({
|
|
16
|
+
* service: "roadie",
|
|
17
|
+
* resolveContext: ({ methodName, args, instance }) => {
|
|
18
|
+
* const meta = args[1] as CallMeta;
|
|
19
|
+
* return {
|
|
20
|
+
* requestId: meta.requestId,
|
|
21
|
+
* actorKind: meta.actor.kind,
|
|
22
|
+
* actorId: actorIdFor(meta.actor),
|
|
23
|
+
* callerApp: readCallerApp(instance, meta),
|
|
24
|
+
* };
|
|
25
|
+
* },
|
|
26
|
+
* deriveOutcome: (ret) => ret.ok ? "ok" : ret.error,
|
|
27
|
+
* onError: (e) => err("internal_error", String(e)),
|
|
28
|
+
* })
|
|
29
|
+
* export class Roadie extends WorkerEntrypoint<RoadieEnv> {
|
|
30
|
+
* async signPart(input, meta) {
|
|
31
|
+
* requireRequestLog().add({ reference_id: input.referenceId });
|
|
32
|
+
* return ok(presigned);
|
|
33
|
+
* }
|
|
34
|
+
*
|
|
35
|
+
* @logged.skip
|
|
36
|
+
* async healthCheck() { return ok({ status: "healthy" }); }
|
|
37
|
+
* }
|
|
38
|
+
*/
|
|
39
|
+
import { type CanonicalLogContext, describeThrown, withCanonicalLog } from "./core";
|
|
40
|
+
|
|
41
|
+
export interface InstrumentationConfig {
|
|
42
|
+
/** Service name for emitted lines — `"roadie"`, `"guestlist"`, `"promoter"`. */
|
|
43
|
+
service: string;
|
|
44
|
+
/**
|
|
45
|
+
* Resolve per-call canonical log context from method args/instance.
|
|
46
|
+
* Returns context fields excluding `service`/`event`/`operation` (filled
|
|
47
|
+
* by the wrapper). May override `operation` to use a non-default name.
|
|
48
|
+
*/
|
|
49
|
+
resolveContext: (args: { methodName: string; args: unknown[]; instance: unknown }) => Omit<
|
|
50
|
+
CanonicalLogContext,
|
|
51
|
+
"service" | "event" | "operation"
|
|
52
|
+
> & {
|
|
53
|
+
operation?: string;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Inspect a method's normal return value to derive outcome. Useful for
|
|
57
|
+
* `Result<T, E>`-discriminant inference. Called only on normal return;
|
|
58
|
+
* thrown errors set outcome to `"internal_error"` automatically. Return
|
|
59
|
+
* `undefined` to keep the default `"ok"` outcome.
|
|
60
|
+
*/
|
|
61
|
+
deriveOutcome?: (returnValue: unknown) => string | undefined;
|
|
62
|
+
/**
|
|
63
|
+
* Convert a thrown error into a return value. Used by services with
|
|
64
|
+
* Result-typed APIs (like Roadie) that document "methods never throw —
|
|
65
|
+
* always return Result<T, E>". When set, exceptions are caught, the
|
|
66
|
+
* line emits with `outcome: "internal_error"` + `error_message`, and
|
|
67
|
+
* the handler's return value is returned to the caller in place of
|
|
68
|
+
* rethrowing. When unset (default), exceptions propagate per kit's
|
|
69
|
+
* primitive behavior.
|
|
70
|
+
*/
|
|
71
|
+
onError?: (e: unknown) => unknown;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const SKIPPED = new WeakSet<object>();
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Method-level decorator. Mark a method to be excluded from class-level
|
|
78
|
+
* `@instrumented` wrapping — useful for health checks, internal helpers,
|
|
79
|
+
* or methods that emit their own log lines via `withCanonicalLog` directly.
|
|
80
|
+
*/
|
|
81
|
+
export const logged = {
|
|
82
|
+
skip<This, Args extends unknown[], Return>(
|
|
83
|
+
target: (this: This, ...args: Args) => Return,
|
|
84
|
+
_context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
|
|
85
|
+
): (this: This, ...args: Args) => Return {
|
|
86
|
+
SKIPPED.add(target as object);
|
|
87
|
+
return target;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// biome-ignore lint/suspicious/noExplicitAny: class decorators need broad constructor signatures
|
|
92
|
+
type Constructor = abstract new (...args: any[]) => object;
|
|
93
|
+
|
|
94
|
+
function makeWrapped(
|
|
95
|
+
name: string,
|
|
96
|
+
original: (...args: unknown[]) => unknown,
|
|
97
|
+
config: InstrumentationConfig,
|
|
98
|
+
) {
|
|
99
|
+
return async function wrapped(this: unknown, ...args: unknown[]) {
|
|
100
|
+
let partial: ReturnType<InstrumentationConfig["resolveContext"]>;
|
|
101
|
+
try {
|
|
102
|
+
partial = config.resolveContext({ methodName: name, args, instance: this });
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// `resolveContext` runs BEFORE the main scope opens and can throw — e.g. a
|
|
105
|
+
// misconfigured RPC binding whose `caller_app` can't be resolved. Without
|
|
106
|
+
// this guard that throw escapes with NO canonical line AND bypasses
|
|
107
|
+
// `onError`, so the runtime records `outcome:"exception"` with empty
|
|
108
|
+
// `logs`/`exceptions`: a fatal, every-request misconfiguration that is
|
|
109
|
+
// completely invisible in the tail — the single worst observability hole.
|
|
110
|
+
// Open a minimal scope (correlation fields still resolve from ALS at emit
|
|
111
|
+
// time) so the failure ALWAYS emits an actionable line, then honor
|
|
112
|
+
// `onError`/rethrow exactly like the body path below.
|
|
113
|
+
const { message, stack } = describeThrown(e);
|
|
114
|
+
return withCanonicalLog(
|
|
115
|
+
{ service: config.service, event: "rpc", operation: `${config.service}.${name}` },
|
|
116
|
+
async (log) => {
|
|
117
|
+
log.outcome("internal_error");
|
|
118
|
+
log.add({
|
|
119
|
+
error_message: message,
|
|
120
|
+
error_stack: stack ?? message,
|
|
121
|
+
error_phase: "resolve_context",
|
|
122
|
+
});
|
|
123
|
+
if (!config.onError) throw e;
|
|
124
|
+
return config.onError(e);
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const operation = partial.operation ?? `${config.service}.${name}`;
|
|
129
|
+
return withCanonicalLog(
|
|
130
|
+
{
|
|
131
|
+
service: config.service,
|
|
132
|
+
event: "rpc",
|
|
133
|
+
operation,
|
|
134
|
+
requestId: partial.requestId,
|
|
135
|
+
actorKind: partial.actorKind,
|
|
136
|
+
actorId: partial.actorId,
|
|
137
|
+
callerApp: partial.callerApp,
|
|
138
|
+
errorOutcomes: partial.errorOutcomes,
|
|
139
|
+
forbiddenFields: partial.forbiddenFields,
|
|
140
|
+
forbiddenPrefixes: partial.forbiddenPrefixes,
|
|
141
|
+
},
|
|
142
|
+
async (log) => {
|
|
143
|
+
let result: unknown;
|
|
144
|
+
try {
|
|
145
|
+
result = await (original as (...a: unknown[]) => Promise<unknown>).apply(this, args);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
if (!config.onError) throw e;
|
|
148
|
+
const { message } = describeThrown(e);
|
|
149
|
+
log.outcome("internal_error");
|
|
150
|
+
log.add({ error_message: message });
|
|
151
|
+
return config.onError(e);
|
|
152
|
+
}
|
|
153
|
+
if (config.deriveOutcome) {
|
|
154
|
+
const outcome = config.deriveOutcome(result);
|
|
155
|
+
if (outcome !== undefined) log.outcome(outcome);
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function applyInstrumentation<T extends Constructor>(
|
|
164
|
+
target: T,
|
|
165
|
+
config: InstrumentationConfig,
|
|
166
|
+
skip?: ReadonlySet<string>,
|
|
167
|
+
): T {
|
|
168
|
+
const proto = target.prototype as Record<string, unknown>;
|
|
169
|
+
for (const name of Object.getOwnPropertyNames(proto)) {
|
|
170
|
+
if (name === "constructor") continue;
|
|
171
|
+
if (skip?.has(name)) continue;
|
|
172
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, name);
|
|
173
|
+
if (!descriptor || typeof descriptor.value !== "function") continue;
|
|
174
|
+
const original = descriptor.value as (...args: unknown[]) => unknown;
|
|
175
|
+
if (SKIPPED.has(original as object)) continue;
|
|
176
|
+
Object.defineProperty(proto, name, {
|
|
177
|
+
...descriptor,
|
|
178
|
+
value: makeWrapped(name, original, config),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return target;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function instrumented(config: InstrumentationConfig) {
|
|
185
|
+
return function decorate<T extends Constructor>(
|
|
186
|
+
target: T,
|
|
187
|
+
_context: ClassDecoratorContext<T>,
|
|
188
|
+
): T {
|
|
189
|
+
return applyInstrumentation(target, config);
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled-job wrapper. Use to wrap Worker `scheduled()` handlers and
|
|
3
|
+
* any background task that runs outside an inbound request — synthesizes
|
|
4
|
+
* a `requestId` (crypto.randomUUID by default; pass `generateRequestId`
|
|
5
|
+
* to use a ulid factory or your platform's tracing ID), sets
|
|
6
|
+
* `actor: { kind: "service", id: service }`, and emits the canonical
|
|
7
|
+
* line as `event: "job"`.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
*
|
|
11
|
+
* import { loggedJob, requireRequestLog } from "@somewhatintelligent/kit/log";
|
|
12
|
+
* import { ulid } from "@somewhatintelligent/kit/ids";
|
|
13
|
+
*
|
|
14
|
+
* const reapExpiredGrants = loggedJob(
|
|
15
|
+
* {
|
|
16
|
+
* service: "roadie",
|
|
17
|
+
* operation: "roadie.job.reap_expired_grants",
|
|
18
|
+
* generateRequestId: () => ulid(),
|
|
19
|
+
* },
|
|
20
|
+
* async (env: Env, ctx: ExecutionContext) => {
|
|
21
|
+
* const reaped = await sweepGrants(env);
|
|
22
|
+
* requireRequestLog().add({ reaped_count: reaped.length });
|
|
23
|
+
* },
|
|
24
|
+
* );
|
|
25
|
+
*
|
|
26
|
+
* export default {
|
|
27
|
+
* async scheduled(_controller, env, ctx) {
|
|
28
|
+
* await reapExpiredGrants(env, ctx);
|
|
29
|
+
* },
|
|
30
|
+
* };
|
|
31
|
+
*/
|
|
32
|
+
import { withRequestContext } from "../request-context";
|
|
33
|
+
import { type CanonicalLogContext, withCanonicalLog } from "./core";
|
|
34
|
+
|
|
35
|
+
type ExtraContext = Partial<
|
|
36
|
+
Omit<
|
|
37
|
+
CanonicalLogContext,
|
|
38
|
+
"service" | "event" | "operation" | "requestId" | "actorKind" | "actorId"
|
|
39
|
+
>
|
|
40
|
+
>;
|
|
41
|
+
|
|
42
|
+
export interface JobLogConfig {
|
|
43
|
+
service: string;
|
|
44
|
+
operation: string;
|
|
45
|
+
/** Synthesize a requestId for this job run. Default: `crypto.randomUUID()`. */
|
|
46
|
+
generateRequestId?: () => string;
|
|
47
|
+
/** Optional extra context fields (callerApp, custom forbiddenFields, etc). */
|
|
48
|
+
resolveContext?: () => Promise<ExtraContext> | ExtraContext;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Opens both the canonical-log scope (for the `event: "job"` line) and the
|
|
52
|
+
// request-context ALS scope so the handler — and anything it calls — can
|
|
53
|
+
// read the synthesized requestId via `getRequestId()` without closure
|
|
54
|
+
// tricks. Matches the pattern app fetch handlers use.
|
|
55
|
+
export function loggedJob<Args extends unknown[], Return>(
|
|
56
|
+
config: JobLogConfig,
|
|
57
|
+
handler: (...args: Args) => Promise<Return>,
|
|
58
|
+
): (...args: Args) => Promise<Return> {
|
|
59
|
+
return async (...args) => {
|
|
60
|
+
const requestId = config.generateRequestId?.() ?? crypto.randomUUID();
|
|
61
|
+
const extra = (await config.resolveContext?.()) ?? {};
|
|
62
|
+
return withRequestContext(
|
|
63
|
+
{
|
|
64
|
+
requestId,
|
|
65
|
+
actorKind: "service",
|
|
66
|
+
actorId: config.service,
|
|
67
|
+
callerApp: extra.callerApp,
|
|
68
|
+
},
|
|
69
|
+
() =>
|
|
70
|
+
withCanonicalLog(
|
|
71
|
+
{
|
|
72
|
+
...extra,
|
|
73
|
+
service: config.service,
|
|
74
|
+
event: "job",
|
|
75
|
+
operation: config.operation,
|
|
76
|
+
requestId,
|
|
77
|
+
actorKind: "service",
|
|
78
|
+
actorId: config.service,
|
|
79
|
+
},
|
|
80
|
+
async () => handler(...args),
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-independent auth context.
|
|
3
|
+
*
|
|
4
|
+
* `createAuthContext<S>()` returns a `{ Context, useAuth, BaseAuthProvider }`
|
|
5
|
+
* triple. `BaseAuthProvider` is a thin wrapper around `Context.Provider` —
|
|
6
|
+
* it does NOT manage state, fetch sessions, or know about any data-fetching
|
|
7
|
+
* framework. State + refetch is layered on top by framework-specific
|
|
8
|
+
* providers (see `kit/react-start`'s `createReactStartAuthProvider` for the
|
|
9
|
+
* TanStack Start binding; future Expo / Storybook bindings would re-implement
|
|
10
|
+
* the provider with their own data idiom but reuse the same `useAuth()`
|
|
11
|
+
* consumer interface).
|
|
12
|
+
*
|
|
13
|
+
* Apps glue it together once per app:
|
|
14
|
+
*
|
|
15
|
+
* const authContext = createAuthContext<PlatformSession>();
|
|
16
|
+
* export const { useAuth } = authContext;
|
|
17
|
+
* export const AuthProvider = createReactStartAuthProvider({
|
|
18
|
+
* authContext,
|
|
19
|
+
* loadSession,
|
|
20
|
+
* });
|
|
21
|
+
*/
|
|
22
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
23
|
+
|
|
24
|
+
export interface AuthContextValue<S> {
|
|
25
|
+
/** Current session value. `null` when unauthenticated. */
|
|
26
|
+
session: S | null;
|
|
27
|
+
/** Convenience: `session?.user ?? null`. */
|
|
28
|
+
user: S extends { user: infer U } ? U | null : null;
|
|
29
|
+
/** `session != null`. */
|
|
30
|
+
isAuthenticated: boolean;
|
|
31
|
+
/** `true` while a refetch is in-flight. */
|
|
32
|
+
isLoading: boolean;
|
|
33
|
+
/** Re-runs the loader; updates `session`/`isLoading` state. */
|
|
34
|
+
refetch: () => Promise<void>;
|
|
35
|
+
/** Strict equality match against `session.user.role`. */
|
|
36
|
+
hasRole: (role: string) => boolean;
|
|
37
|
+
/** Returns `true` if the user's role is in `roles`. */
|
|
38
|
+
hasAnyRole: (roles: ReadonlyArray<string>) => boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createAuthContext<S extends { user: { role?: string | null } }>() {
|
|
42
|
+
const Ctx = createContext<AuthContextValue<S> | null>(null);
|
|
43
|
+
|
|
44
|
+
function useAuth(): AuthContextValue<S> {
|
|
45
|
+
const value = useContext(Ctx);
|
|
46
|
+
if (!value) {
|
|
47
|
+
throw new Error("useAuth() must be called inside an AuthProvider");
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function BaseAuthProvider({
|
|
53
|
+
value,
|
|
54
|
+
children,
|
|
55
|
+
}: {
|
|
56
|
+
value: AuthContextValue<S>;
|
|
57
|
+
children: ReactNode;
|
|
58
|
+
}) {
|
|
59
|
+
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { Context: Ctx, useAuth, BaseAuthProvider };
|
|
63
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { PLATFORM_HEADERS } from "@somewhatintelligent/auth";
|
|
3
|
+
import { createDevEnvelopeStamper } from "../dev-envelope";
|
|
4
|
+
|
|
5
|
+
// Fixture dev host — the stamper is host-agnostic; any dev-shaped host works.
|
|
6
|
+
const chatDevHost = "chat.platform.localhost";
|
|
7
|
+
|
|
8
|
+
// Well-known dev-only keypair fixture; the matching public half is what a
|
|
9
|
+
// consumer commits in its keyset under `kid: "dev"`. Safe to
|
|
10
|
+
// inline here for tests; this is the same key bouncer's dev `.dev.vars` uses.
|
|
11
|
+
const DEV_PRIV_PEM = `-----BEGIN PRIVATE KEY-----
|
|
12
|
+
MC4CAQAwBQYDK2VwBCIEINzNgiuDD9xbqVEPkfMt8twPcq7hTnIbAdKKHPjM7TmU
|
|
13
|
+
-----END PRIVATE KEY-----`;
|
|
14
|
+
|
|
15
|
+
const userFixture = {
|
|
16
|
+
id: "user_test",
|
|
17
|
+
role: "member",
|
|
18
|
+
name: "Alice",
|
|
19
|
+
email: "alice@example.com",
|
|
20
|
+
image: null,
|
|
21
|
+
};
|
|
22
|
+
const sessionFixture = {
|
|
23
|
+
id: "sess_test",
|
|
24
|
+
userId: "user_test",
|
|
25
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function makeGuestlist(opts: {
|
|
29
|
+
session?: { user: typeof userFixture; session: typeof sessionFixture } | null;
|
|
30
|
+
calls?: { count: number };
|
|
31
|
+
}) {
|
|
32
|
+
return (_req: Request) => ({
|
|
33
|
+
getSession: async () => {
|
|
34
|
+
if (opts.calls) opts.calls.count += 1;
|
|
35
|
+
return opts.session ?? null;
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("createDevEnvelopeStamper", () => {
|
|
41
|
+
test("non-dev env → hard no-op (request returned by identity, no cookies)", async () => {
|
|
42
|
+
const calls = { count: 0 };
|
|
43
|
+
for (const envLabel of ["production", "staging", "preview", undefined]) {
|
|
44
|
+
const stamper = createDevEnvelopeStamper({
|
|
45
|
+
getEnvironment: () => envLabel,
|
|
46
|
+
getSigner: () => {
|
|
47
|
+
throw new Error("signer must not be invoked outside dev");
|
|
48
|
+
},
|
|
49
|
+
getGuestlist: makeGuestlist({ session: null, calls }),
|
|
50
|
+
});
|
|
51
|
+
const req = new Request("https://chat.example/");
|
|
52
|
+
const out = await stamper(req);
|
|
53
|
+
expect(out.request).toBe(req);
|
|
54
|
+
expect(out.setCookies).toEqual([]);
|
|
55
|
+
}
|
|
56
|
+
expect(calls.count).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("dev + existing envelope header → passthrough (no remint)", async () => {
|
|
60
|
+
let signerCalls = 0;
|
|
61
|
+
const stamper = createDevEnvelopeStamper({
|
|
62
|
+
getEnvironment: () => "development",
|
|
63
|
+
getSigner: () => {
|
|
64
|
+
signerCalls += 1;
|
|
65
|
+
return { privPem: DEV_PRIV_PEM, kid: "dev" };
|
|
66
|
+
},
|
|
67
|
+
getGuestlist: makeGuestlist({ session: null }),
|
|
68
|
+
});
|
|
69
|
+
const req = new Request("https://chat.example/", {
|
|
70
|
+
headers: { [PLATFORM_HEADERS.att]: "preexisting.jws.value" },
|
|
71
|
+
});
|
|
72
|
+
const out = await stamper(req);
|
|
73
|
+
expect(out.request).toBe(req);
|
|
74
|
+
expect(out.request.headers.get(PLATFORM_HEADERS.att)).toBe("preexisting.jws.value");
|
|
75
|
+
expect(out.setCookies).toEqual([]);
|
|
76
|
+
expect(signerCalls).toBe(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("dev + no envelope + authed session → stamps a JWS-compact envelope", async () => {
|
|
80
|
+
const stamper = createDevEnvelopeStamper({
|
|
81
|
+
getEnvironment: () => "development",
|
|
82
|
+
getSigner: () => ({ privPem: DEV_PRIV_PEM, kid: "dev" }),
|
|
83
|
+
getGuestlist: makeGuestlist({
|
|
84
|
+
session: { user: userFixture, session: sessionFixture },
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
const req = new Request("https://chat.example/whatever");
|
|
88
|
+
const out = await stamper(req);
|
|
89
|
+
expect(out.request).not.toBe(req);
|
|
90
|
+
const att = out.request.headers.get(PLATFORM_HEADERS.att);
|
|
91
|
+
expect(att).toBeTruthy();
|
|
92
|
+
const parts = att!.split(".");
|
|
93
|
+
expect(parts).toHaveLength(3);
|
|
94
|
+
const payload = JSON.parse(
|
|
95
|
+
new TextDecoder().decode(
|
|
96
|
+
Uint8Array.from(atob(parts[1]!.replace(/-/g, "+").replace(/_/g, "/")), (c) =>
|
|
97
|
+
c.charCodeAt(0),
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
expect(payload.iss).toBe("bouncer");
|
|
102
|
+
expect(payload.host).toBe("chat.example");
|
|
103
|
+
expect(payload.actor).toMatchObject({
|
|
104
|
+
kind: "user",
|
|
105
|
+
id: "user_test",
|
|
106
|
+
role: "member",
|
|
107
|
+
name: "Alice",
|
|
108
|
+
email: "alice@example.com",
|
|
109
|
+
});
|
|
110
|
+
expect(payload.session).toMatchObject({ id: "sess_test", userId: "user_test" });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("dev + no envelope + no session → still mints a signed envelope with actor:null", async () => {
|
|
114
|
+
const stamper = createDevEnvelopeStamper({
|
|
115
|
+
getEnvironment: () => "development",
|
|
116
|
+
getSigner: () => ({ privPem: DEV_PRIV_PEM, kid: "dev" }),
|
|
117
|
+
getGuestlist: makeGuestlist({ session: null }),
|
|
118
|
+
});
|
|
119
|
+
const req = new Request("https://chat.example/public");
|
|
120
|
+
const out = await stamper(req);
|
|
121
|
+
const att = out.request.headers.get(PLATFORM_HEADERS.att);
|
|
122
|
+
expect(att).toBeTruthy();
|
|
123
|
+
const payload = JSON.parse(
|
|
124
|
+
new TextDecoder().decode(
|
|
125
|
+
Uint8Array.from(atob(att!.split(".")[1]!.replace(/-/g, "+").replace(/_/g, "/")), (c) =>
|
|
126
|
+
c.charCodeAt(0),
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
expect(payload.actor).toBeNull();
|
|
131
|
+
expect(payload.session).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("expectedHost override pins payload.host", async () => {
|
|
135
|
+
const stamper = createDevEnvelopeStamper({
|
|
136
|
+
getEnvironment: () => "development",
|
|
137
|
+
getSigner: () => ({ privPem: DEV_PRIV_PEM, kid: "dev" }),
|
|
138
|
+
getGuestlist: makeGuestlist({ session: null }),
|
|
139
|
+
expectedHost: chatDevHost,
|
|
140
|
+
});
|
|
141
|
+
const req = new Request("http://127.0.0.1:8788/");
|
|
142
|
+
const out = await stamper(req);
|
|
143
|
+
const att = out.request.headers.get(PLATFORM_HEADERS.att)!;
|
|
144
|
+
const payload = JSON.parse(
|
|
145
|
+
new TextDecoder().decode(
|
|
146
|
+
Uint8Array.from(atob(att.split(".")[1]!.replace(/-/g, "+").replace(/_/g, "/")), (c) =>
|
|
147
|
+
c.charCodeAt(0),
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
expect(payload.host).toBe(chatDevHost);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createGuestlistFactory,
|
|
4
|
+
createLoggingFunctionMiddleware,
|
|
5
|
+
createRequestLogger,
|
|
6
|
+
createRoadieFactory,
|
|
7
|
+
createSessionFactory,
|
|
8
|
+
} from "../index";
|
|
9
|
+
import { createReactStartAuthProvider } from "../client";
|
|
10
|
+
import { createAuthContext } from "../../react";
|
|
11
|
+
|
|
12
|
+
type FakeSession = { user: { id: string; email: string; role?: string | null } };
|
|
13
|
+
|
|
14
|
+
// Smoke: each factory returns a TSS middleware object with an `options.server`
|
|
15
|
+
// callback. Behavioural coverage lives in `kit/log` and `kit/request-context`
|
|
16
|
+
// — these tests exist to catch wiring regressions (missing peer-dep, broken
|
|
17
|
+
// re-export, factory signature drift).
|
|
18
|
+
describe("kit/react-start factories return TSS middleware objects", () => {
|
|
19
|
+
test("createLoggingFunctionMiddleware", () => {
|
|
20
|
+
const m = createLoggingFunctionMiddleware({ service: "test" });
|
|
21
|
+
expect(m).toBeDefined();
|
|
22
|
+
expect(typeof (m as { options?: { server?: unknown } }).options?.server).toBe("function");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("createRequestLogger", () => {
|
|
26
|
+
const m = createRequestLogger({ service: "test" });
|
|
27
|
+
expect(m).toBeDefined();
|
|
28
|
+
expect(typeof (m as { options?: { server?: unknown } }).options?.server).toBe("function");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("kit/react-start service-client factories", () => {
|
|
33
|
+
test("createGuestlistFactory returns a callable", () => {
|
|
34
|
+
const getGuestlist = createGuestlistFactory({
|
|
35
|
+
callerApp: "test",
|
|
36
|
+
createClient: () => ({ ok: true }),
|
|
37
|
+
fetcher: () => globalThis.fetch,
|
|
38
|
+
});
|
|
39
|
+
expect(typeof getGuestlist).toBe("function");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("createRoadieFactory returns a callable", () => {
|
|
43
|
+
const getRoadie = createRoadieFactory({
|
|
44
|
+
callerApp: "test",
|
|
45
|
+
createClient: () => ({ ok: true }),
|
|
46
|
+
getBinding: () => ({}) as unknown,
|
|
47
|
+
});
|
|
48
|
+
expect(typeof getRoadie).toBe("function");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("createSessionFactory returns a createServerOnlyFn-wrapped getSession", () => {
|
|
52
|
+
const getSession = createSessionFactory<FakeSession>({
|
|
53
|
+
getGuestlistFallback: async () => null,
|
|
54
|
+
});
|
|
55
|
+
expect(typeof getSession).toBe("function");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("kit/react-start auth-provider factory", () => {
|
|
60
|
+
test("createReactStartAuthProvider returns a React component", () => {
|
|
61
|
+
const authContext = createAuthContext<FakeSession>();
|
|
62
|
+
const Provider = createReactStartAuthProvider<FakeSession>({
|
|
63
|
+
authContext,
|
|
64
|
+
loadSession: async () => null,
|
|
65
|
+
});
|
|
66
|
+
expect(typeof Provider).toBe("function");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("kit/react createAuthContext", () => {
|
|
71
|
+
test("returns { Context, useAuth, BaseAuthProvider }", () => {
|
|
72
|
+
const ctx = createAuthContext<FakeSession>();
|
|
73
|
+
expect(ctx.Context).toBeDefined();
|
|
74
|
+
expect(typeof ctx.useAuth).toBe("function");
|
|
75
|
+
expect(typeof ctx.BaseAuthProvider).toBe("function");
|
|
76
|
+
});
|
|
77
|
+
});
|