@willyim/idp 0.1.0 → 0.2.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.
Files changed (42) hide show
  1. package/README.md +83 -13
  2. package/dist/src/api.d.ts +31 -55
  3. package/dist/src/api.d.ts.map +1 -1
  4. package/dist/src/api.js +16 -11
  5. package/dist/src/claims.d.ts +9 -34
  6. package/dist/src/claims.d.ts.map +1 -1
  7. package/dist/src/claims.js +12 -40
  8. package/dist/src/client.d.ts +30 -34
  9. package/dist/src/client.d.ts.map +1 -1
  10. package/dist/src/client.js +16 -22
  11. package/dist/src/drizzle/index.d.ts +78 -13
  12. package/dist/src/drizzle/index.d.ts.map +1 -1
  13. package/dist/src/errors.d.ts +8 -0
  14. package/dist/src/errors.d.ts.map +1 -0
  15. package/dist/src/errors.js +12 -0
  16. package/dist/src/index.d.ts +3 -1
  17. package/dist/src/index.d.ts.map +1 -1
  18. package/dist/src/index.js +3 -1
  19. package/dist/src/schemas/index.d.ts +207 -0
  20. package/dist/src/schemas/index.d.ts.map +1 -0
  21. package/dist/src/schemas/index.js +122 -0
  22. package/dist/src/schemas/openapi.d.ts +31 -0
  23. package/dist/src/schemas/openapi.d.ts.map +1 -0
  24. package/dist/src/schemas/openapi.js +110 -0
  25. package/dist/src/schemas/operations.d.ts +297 -0
  26. package/dist/src/schemas/operations.d.ts.map +1 -0
  27. package/dist/src/schemas/operations.js +118 -0
  28. package/dist/src/session.d.ts +17 -3
  29. package/dist/src/session.d.ts.map +1 -1
  30. package/dist/src/session.js +12 -1
  31. package/dist/src/user-keys.d.ts +124 -0
  32. package/dist/src/user-keys.d.ts.map +1 -0
  33. package/dist/src/user-keys.js +174 -0
  34. package/dist/src/validate.d.ts +13 -0
  35. package/dist/src/validate.d.ts.map +1 -0
  36. package/dist/src/validate.js +28 -0
  37. package/dist/src/wire.d.ts +164 -0
  38. package/dist/src/wire.d.ts.map +1 -0
  39. package/dist/src/wire.js +100 -0
  40. package/openapi/idp-api.json +3 -6
  41. package/package.json +16 -7
  42. package/dist/src/generated/idp-api.d.ts +0 -1022
@@ -0,0 +1,124 @@
1
+ /**
2
+ * End-user API keys, from the consuming app's side.
3
+ *
4
+ * The IdP is the key store: an app mints, lists, revokes and validates `wak_…`
5
+ * keys through the management API, authenticated with its own scoped `wim_…`
6
+ * key, and never persists a plaintext token or a hash of one. This module is
7
+ * the sugar over those four calls, plus the two things every consumer would
8
+ * otherwise write badly by hand:
9
+ *
10
+ * - a validation cache. `validate` is a network round trip, and API keys
11
+ * arrive on the request-per-request hot path. Results are cached by digest
12
+ * of the token, with a short TTL, and concurrent validations of the same
13
+ * token share one in-flight request. The cost is revocation lag bounded by
14
+ * `cache.ttlMs` — pick it deliberately, and call `forget` after a revoke you
15
+ * performed yourself.
16
+ * - `authenticate`, which reads the bearer token off a `Request`, validates it
17
+ * and checks required scopes, returning a discriminated result rather than
18
+ * throwing, so the caller decides what a 401 looks like.
19
+ *
20
+ * Only for *secret* credentials. A public write key embedded in a page (an
21
+ * analytics ingest token, say) identifies a site rather than a user, cannot be
22
+ * kept secret, and must not pay a round trip per hit — keep those in the app's
23
+ * own table.
24
+ */
25
+ import type { z } from "zod";
26
+ import { type ManagementApiOptions } from "./api.js";
27
+ import type { CreateUserApiKeyInput, UserApiKeyCreatedSchema, UserApiKeySchema, UserApiKeyValidationSchema } from "./schemas/index.js";
28
+ type CreateBody = z.input<typeof CreateUserApiKeyInput>;
29
+ /** One key as the IdP reports it. Never includes the token or its hash. */
30
+ export type UserApiKey = z.output<typeof UserApiKeySchema>;
31
+ /** What `create` hands back. `token` is the only time the plaintext exists. */
32
+ export type MintedUserApiKey = z.output<typeof UserApiKeyCreatedSchema>;
33
+ /** A validation verdict. A miss is data, not an error — hence `valid: false`. */
34
+ export type UserKeyValidation = z.output<typeof UserApiKeyValidationSchema>;
35
+ /** The `valid: true` half, i.e. an authenticated key. */
36
+ export type AuthenticatedKey = Extract<UserKeyValidation, {
37
+ valid: true;
38
+ }>;
39
+ export type UserKeyCacheOptions = {
40
+ /** How long a `valid: true` verdict is reused. Default 60s. */
41
+ ttlMs?: number;
42
+ /** How long a `valid: false` verdict is reused. Default 10s. */
43
+ missTtlMs?: number;
44
+ /** Entry ceiling before the oldest are dropped. Default 1000. */
45
+ max?: number;
46
+ };
47
+ export type UserKeysOptions = ManagementApiOptions & {
48
+ /** The app key these keys belong to — `oauth_client.metadata.app`. */
49
+ app: string;
50
+ /** `false` disables caching entirely (every validate is a round trip). */
51
+ cache?: UserKeyCacheOptions | false;
52
+ /** Clock seam, for tests. */
53
+ now?: () => number;
54
+ };
55
+ export type ListFilter = {
56
+ userId?: string;
57
+ workspaceId?: string;
58
+ signal?: AbortSignal;
59
+ };
60
+ export type CreateUserApiKeyInput = CreateBody & {
61
+ signal?: AbortSignal;
62
+ };
63
+ export type AuthenticateOptions = {
64
+ /** Every scope listed must be present on the key. */
65
+ scopes?: string[];
66
+ signal?: AbortSignal;
67
+ };
68
+ export type AuthenticateResult = {
69
+ ok: true;
70
+ key: AuthenticatedKey;
71
+ } | {
72
+ ok: false;
73
+ status: 401 | 403;
74
+ reason: "missing" | "not_found" | "revoked" | "expired" | "insufficient_scope";
75
+ /** The scopes that were required but absent, when `insufficient_scope`. */
76
+ missing?: string[];
77
+ };
78
+ /**
79
+ * Reads a presented key off a request: `Authorization: Bearer …` first, then
80
+ * `X-API-Key`. Returns null when neither is present, so "no credential" stays
81
+ * distinguishable from "bad credential".
82
+ */
83
+ export declare function readApiKey(request: {
84
+ headers: Headers;
85
+ }): string | null;
86
+ export declare function createUserKeys(options: UserKeysOptions): {
87
+ validate: (token: string, init?: {
88
+ signal?: AbortSignal;
89
+ fresh?: boolean;
90
+ }) => Promise<UserKeyValidation>;
91
+ /** The keys this app has minted, newest first. Optionally filtered. */
92
+ list(filter?: ListFilter): Promise<UserApiKey[]>;
93
+ /**
94
+ * Mints a key for one of the app's users. The returned `token` is the only
95
+ * copy — show it once and forget it. Scopes must come from the app's
96
+ * declared product permission catalog; unknown ones are a 422, not a
97
+ * silent drop.
98
+ */
99
+ create(input: CreateUserApiKeyInput): Promise<MintedUserApiKey>;
100
+ /**
101
+ * Revokes a key by id (idempotent). Cached verdicts for *other* tokens are
102
+ * untouched; this app never saw the revoked plaintext, so the entry for it
103
+ * can only expire on its own TTL. Call `forget(token)` instead when the
104
+ * plaintext is in hand.
105
+ */
106
+ revoke(id: string, init?: {
107
+ signal?: AbortSignal;
108
+ }): Promise<{
109
+ ok: true;
110
+ }>;
111
+ /**
112
+ * The whole check in one call: read the credential off the request,
113
+ * validate it, and confirm every required scope. Returns a result rather
114
+ * than throwing, so the caller owns the response shape.
115
+ */
116
+ authenticate(request: {
117
+ headers: Headers;
118
+ }, init?: AuthenticateOptions): Promise<AuthenticateResult>;
119
+ /** Drops one token's cached verdict, or the whole cache when called bare. */
120
+ forget(token?: string): Promise<void>;
121
+ };
122
+ export type UserKeys = ReturnType<typeof createUserKeys>;
123
+ export {};
124
+ //# sourceMappingURL=user-keys.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user-keys.d.ts","sourceRoot":"","sources":["../../src/user-keys.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,174 @@
1
+ /**
2
+ * End-user API keys, from the consuming app's side.
3
+ *
4
+ * The IdP is the key store: an app mints, lists, revokes and validates `wak_…`
5
+ * keys through the management API, authenticated with its own scoped `wim_…`
6
+ * key, and never persists a plaintext token or a hash of one. This module is
7
+ * the sugar over those four calls, plus the two things every consumer would
8
+ * otherwise write badly by hand:
9
+ *
10
+ * - a validation cache. `validate` is a network round trip, and API keys
11
+ * arrive on the request-per-request hot path. Results are cached by digest
12
+ * of the token, with a short TTL, and concurrent validations of the same
13
+ * token share one in-flight request. The cost is revocation lag bounded by
14
+ * `cache.ttlMs` — pick it deliberately, and call `forget` after a revoke you
15
+ * performed yourself.
16
+ * - `authenticate`, which reads the bearer token off a `Request`, validates it
17
+ * and checks required scopes, returning a discriminated result rather than
18
+ * throwing, so the caller decides what a 401 looks like.
19
+ *
20
+ * Only for *secret* credentials. A public write key embedded in a page (an
21
+ * analytics ingest token, say) identifies a site rather than a user, cannot be
22
+ * kept secret, and must not pay a round trip per hit — keep those in the app's
23
+ * own table.
24
+ */
25
+ import { createManagementApi } from "./api.js";
26
+ import { sha256Base64url } from "./crypto.js";
27
+ const DEFAULT_TTL_MS = 60_000;
28
+ const DEFAULT_MISS_TTL_MS = 10_000;
29
+ const DEFAULT_MAX = 1000;
30
+ /**
31
+ * Reads a presented key off a request: `Authorization: Bearer …` first, then
32
+ * `X-API-Key`. Returns null when neither is present, so "no credential" stays
33
+ * distinguishable from "bad credential".
34
+ */
35
+ export function readApiKey(request) {
36
+ const authorization = request.headers.get("authorization");
37
+ if (authorization) {
38
+ const [scheme, ...rest] = authorization.split(" ");
39
+ const value = rest.join(" ").trim();
40
+ if (scheme?.toLowerCase() === "bearer" && value)
41
+ return value;
42
+ }
43
+ return request.headers.get("x-api-key")?.trim() || null;
44
+ }
45
+ export function createUserKeys(options) {
46
+ const api = createManagementApi(options);
47
+ const app = options.app;
48
+ const now = options.now ?? (() => Date.now());
49
+ const caching = options.cache !== false;
50
+ const ttlMs = (options.cache || {}).ttlMs ?? DEFAULT_TTL_MS;
51
+ const missTtlMs = (options.cache || {}).missTtlMs ?? DEFAULT_MISS_TTL_MS;
52
+ const max = (options.cache || {}).max ?? DEFAULT_MAX;
53
+ // Keyed by digest, never by the token itself: a heap dump or a logged Map
54
+ // then leaks nothing usable. Insertion-ordered, so the oldest entry is the
55
+ // first key — good enough eviction for a cache this size.
56
+ const cache = new Map();
57
+ const inFlight = new Map();
58
+ const digest = (token) => sha256Base64url(`user-key:${app}:${token}`);
59
+ function remember(key, verdict) {
60
+ if (!caching)
61
+ return;
62
+ if (cache.size >= max) {
63
+ const oldest = cache.keys().next();
64
+ if (!oldest.done)
65
+ cache.delete(oldest.value);
66
+ }
67
+ cache.set(key, { verdict, expiresAt: now() + (verdict.valid ? ttlMs : missTtlMs) });
68
+ }
69
+ async function fetchVerdict(token, signal) {
70
+ return api.request("post", "/api/v1/apps/{app}/user-keys/validate", {
71
+ params: { app },
72
+ body: { token },
73
+ signal,
74
+ });
75
+ }
76
+ /**
77
+ * Validates a presented token. Served from cache when fresh; concurrent
78
+ * callers presenting the same token share one round trip. `fresh: true`
79
+ * bypasses the cache for that call and reseeds it.
80
+ */
81
+ async function validate(token, init = {}) {
82
+ if (!token)
83
+ return { valid: false, reason: "not_found" };
84
+ const key = await digest(token);
85
+ if (!init.fresh && caching) {
86
+ const hit = cache.get(key);
87
+ if (hit && hit.expiresAt > now())
88
+ return hit.verdict;
89
+ if (hit)
90
+ cache.delete(key);
91
+ const pending = inFlight.get(key);
92
+ if (pending)
93
+ return pending;
94
+ }
95
+ const request = fetchVerdict(token, init.signal)
96
+ .then((verdict) => {
97
+ remember(key, verdict);
98
+ return verdict;
99
+ })
100
+ .finally(() => {
101
+ inFlight.delete(key);
102
+ });
103
+ // A failed round trip must not be cached — an IdP blip would otherwise
104
+ // lock every caller out for the whole TTL.
105
+ if (caching)
106
+ inFlight.set(key, request);
107
+ return request;
108
+ }
109
+ return {
110
+ validate,
111
+ /** The keys this app has minted, newest first. Optionally filtered. */
112
+ async list(filter = {}) {
113
+ const { keys } = await api.request("get", "/api/v1/apps/{app}/user-keys", {
114
+ params: { app },
115
+ query: { userId: filter.userId, workspaceId: filter.workspaceId },
116
+ signal: filter.signal,
117
+ });
118
+ return keys;
119
+ },
120
+ /**
121
+ * Mints a key for one of the app's users. The returned `token` is the only
122
+ * copy — show it once and forget it. Scopes must come from the app's
123
+ * declared product permission catalog; unknown ones are a 422, not a
124
+ * silent drop.
125
+ */
126
+ async create(input) {
127
+ const { signal, ...body } = input;
128
+ return api.request("post", "/api/v1/apps/{app}/user-keys", {
129
+ params: { app },
130
+ body,
131
+ signal,
132
+ });
133
+ },
134
+ /**
135
+ * Revokes a key by id (idempotent). Cached verdicts for *other* tokens are
136
+ * untouched; this app never saw the revoked plaintext, so the entry for it
137
+ * can only expire on its own TTL. Call `forget(token)` instead when the
138
+ * plaintext is in hand.
139
+ */
140
+ async revoke(id, init = {}) {
141
+ return api.request("delete", "/api/v1/apps/{app}/user-keys/{id}", {
142
+ params: { app, id },
143
+ signal: init.signal,
144
+ });
145
+ },
146
+ /**
147
+ * The whole check in one call: read the credential off the request,
148
+ * validate it, and confirm every required scope. Returns a result rather
149
+ * than throwing, so the caller owns the response shape.
150
+ */
151
+ async authenticate(request, init = {}) {
152
+ const token = readApiKey(request);
153
+ if (!token)
154
+ return { ok: false, status: 401, reason: "missing" };
155
+ const verdict = await validate(token, { signal: init.signal });
156
+ if (!verdict.valid)
157
+ return { ok: false, status: 401, reason: verdict.reason };
158
+ const required = init.scopes ?? [];
159
+ const missing = required.filter((scope) => !verdict.scopes.includes(scope));
160
+ if (missing.length) {
161
+ return { ok: false, status: 403, reason: "insufficient_scope", missing };
162
+ }
163
+ return { ok: true, key: verdict };
164
+ },
165
+ /** Drops one token's cached verdict, or the whole cache when called bare. */
166
+ async forget(token) {
167
+ if (token === undefined) {
168
+ cache.clear();
169
+ return;
170
+ }
171
+ cache.delete(await digest(token));
172
+ },
173
+ };
174
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * One place where a wire payload becomes a typed value.
3
+ *
4
+ * Everything the IdP hands us — discovery, tokens, userinfo, management API
5
+ * responses — goes through here. A malformed payload raises an `IdpError` that
6
+ * names the offending field, rather than a `TypeError` three frames later on a
7
+ * property that was never there.
8
+ *
9
+ * `502` is the status: the failure is upstream, not in the caller's request.
10
+ */
11
+ import type { z } from "zod";
12
+ export declare function parseWire<T extends z.ZodType>(schema: T, value: unknown, what: string): z.output<T>;
13
+ //# sourceMappingURL=validate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/validate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAS5B,wBAAgB,SAAS,CAAC,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAWnG"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * One place where a wire payload becomes a typed value.
3
+ *
4
+ * Everything the IdP hands us — discovery, tokens, userinfo, management API
5
+ * responses — goes through here. A malformed payload raises an `IdpError` that
6
+ * names the offending field, rather than a `TypeError` three frames later on a
7
+ * property that was never there.
8
+ *
9
+ * `502` is the status: the failure is upstream, not in the caller's request.
10
+ */
11
+ import { IdpError } from "./errors.js";
12
+ /** `["workspaces", 0, "id"]` -> `"workspaces.0.id"`. */
13
+ function issuePath(path) {
14
+ return path.length ? path.map(String).join(".") : "(root)";
15
+ }
16
+ export function parseWire(schema, value, what) {
17
+ const result = schema.safeParse(value);
18
+ if (result.success)
19
+ return result.data;
20
+ const detail = result.error.issues
21
+ .slice(0, 3)
22
+ .map((issue) => `${issuePath(issue.path)}: ${issue.message}`)
23
+ .join("; ");
24
+ throw new IdpError(`${what} returned a malformed payload (${detail})`, 502, {
25
+ issues: result.error.issues,
26
+ received: value,
27
+ });
28
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The OIDC wire, as zod schemas: discovery, the token endpoint, and the claim
3
+ * set. These are not in `./schemas` with the management API because they are
4
+ * standards-defined — nothing generates them, and they never appear in our
5
+ * OpenAPI document.
6
+ *
7
+ * Two different postures live here on purpose:
8
+ *
9
+ * - **Strict** on anything we act on. Without `token_endpoint` there is no
10
+ * flow to run, and an `access_token` that is absent must not become `""`
11
+ * and get written to a session row.
12
+ * - **Tolerant** on claims. A user with no permissions, no workspaces, or no
13
+ * display name is ordinary, not an error — those degrade to empty. Only
14
+ * `sub` is genuinely required, since it is the identity.
15
+ *
16
+ * Unknown fields pass through everywhere: an IdP is allowed to grow.
17
+ */
18
+ import { z } from "zod";
19
+ export declare const PERMISSIONS_CLAIM = "https://willy.im/permissions";
20
+ export declare const WORKSPACES_CLAIM = "https://willy.im/workspaces";
21
+ /** The subset of the discovery document we use, plus the fields we may. */
22
+ export declare const DiscoverySchema: z.ZodObject<{
23
+ issuer: z.ZodString;
24
+ authorization_endpoint: z.ZodString;
25
+ token_endpoint: z.ZodString;
26
+ userinfo_endpoint: z.ZodString;
27
+ end_session_endpoint: z.ZodOptional<z.ZodString>;
28
+ jwks_uri: z.ZodOptional<z.ZodString>;
29
+ /**
30
+ * Per-app session ceiling, in seconds. Not standard OIDC — an IdP extension
31
+ * the SDK clamps `session.expiresIn` against when present.
32
+ */
33
+ session_max_age: z.ZodOptional<z.ZodNumber>;
34
+ }, z.core.$loose>;
35
+ export type Discovery = z.output<typeof DiscoverySchema>;
36
+ /** Token-endpoint output, camelCased so app code never sees the wire shape. */
37
+ export declare const TokensSchema: z.ZodPipe<z.ZodObject<{
38
+ access_token: z.ZodString;
39
+ token_type: z.ZodDefault<z.ZodString>;
40
+ expires_in: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
41
+ refresh_token: z.ZodOptional<z.ZodNullable<z.ZodString>>;
42
+ id_token: z.ZodOptional<z.ZodNullable<z.ZodString>>;
43
+ scope: z.ZodOptional<z.ZodNullable<z.ZodString>>;
44
+ }, z.core.$loose>, z.ZodTransform<{
45
+ accessToken: string;
46
+ tokenType: string;
47
+ /** Seconds until the access token expires, when the IdP says. */
48
+ expiresIn: number | null;
49
+ refreshToken: string | null;
50
+ idToken: string | null;
51
+ scope: string | null;
52
+ }, {
53
+ [x: string]: unknown;
54
+ access_token: string;
55
+ token_type: string;
56
+ expires_in?: number | null | undefined;
57
+ refresh_token?: string | null | undefined;
58
+ id_token?: string | null | undefined;
59
+ scope?: string | null | undefined;
60
+ }>>;
61
+ export type Tokens = z.output<typeof TokensSchema>;
62
+ /** A tenant inside THIS app. `domain` is set for multi-domain apps, else null. */
63
+ export declare const WorkspaceSchema: z.ZodObject<{
64
+ id: z.ZodString;
65
+ slug: z.ZodDefault<z.ZodString>;
66
+ name: z.ZodDefault<z.ZodString>;
67
+ domain: z.ZodPipe<z.ZodOptional<z.ZodNullable<z.ZodString>>, z.ZodTransform<string | null, string | null | undefined>>;
68
+ role: z.ZodDefault<z.ZodString>;
69
+ }, z.core.$loose>;
70
+ export type Workspace = z.output<typeof WorkspaceSchema>;
71
+ /**
72
+ * The RFC 8693 `act` claim: present while an IdP admin is impersonating the
73
+ * user. Audit-only — tag your logs with it, never branch authorization on it.
74
+ */
75
+ export declare const ActorSchema: z.ZodObject<{
76
+ sub: z.ZodString;
77
+ email: z.ZodOptional<z.ZodString>;
78
+ }, z.core.$loose>;
79
+ export type Actor = z.output<typeof ActorSchema>;
80
+ /**
81
+ * Wire claims -> `Claims`. The `https://willy.im/*` namespace is unwrapped
82
+ * here: namespaced URI claims are an OIDC requirement, not something app code
83
+ * should ever have to type.
84
+ */
85
+ export declare const ClaimsSchema: z.ZodPipe<z.ZodObject<{
86
+ sub: z.ZodString;
87
+ email: z.ZodCatch<z.ZodString>;
88
+ email_verified: z.ZodCatch<z.ZodBoolean>;
89
+ name: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
90
+ picture: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
91
+ image: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
92
+ "https://willy.im/permissions": z.ZodPipe<z.ZodCatch<z.ZodArray<z.ZodCatch<z.ZodNullable<z.ZodString>>>>, z.ZodTransform<string[], (string | null)[]>>;
93
+ "https://willy.im/workspaces": z.ZodPipe<z.ZodCatch<z.ZodArray<z.ZodCatch<z.ZodNullable<z.ZodObject<{
94
+ id: z.ZodString;
95
+ slug: z.ZodDefault<z.ZodString>;
96
+ name: z.ZodDefault<z.ZodString>;
97
+ domain: z.ZodPipe<z.ZodOptional<z.ZodNullable<z.ZodString>>, z.ZodTransform<string | null, string | null | undefined>>;
98
+ role: z.ZodDefault<z.ZodString>;
99
+ }, z.core.$loose>>>>>, z.ZodTransform<{
100
+ [x: string]: unknown;
101
+ id: string;
102
+ slug: string;
103
+ name: string;
104
+ domain: string | null;
105
+ role: string;
106
+ }[], ({
107
+ [x: string]: unknown;
108
+ id: string;
109
+ slug: string;
110
+ name: string;
111
+ domain: string | null;
112
+ role: string;
113
+ } | null)[]>>;
114
+ act: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodObject<{
115
+ sub: z.ZodString;
116
+ email: z.ZodOptional<z.ZodString>;
117
+ }, z.core.$loose>>>>;
118
+ }, z.core.$loose>, z.ZodTransform<{
119
+ sub: string;
120
+ email: string;
121
+ emailVerified: boolean;
122
+ name: string | null;
123
+ image: string | null;
124
+ /** Product permissions granted in this app. Unwrapped from the namespace. */
125
+ permissions: string[];
126
+ /** Workspaces the user belongs to in this app. Unwrapped from the namespace. */
127
+ workspaces: {
128
+ [x: string]: unknown;
129
+ id: string;
130
+ slug: string;
131
+ name: string;
132
+ domain: string | null;
133
+ role: string;
134
+ }[];
135
+ actor: {
136
+ [x: string]: unknown;
137
+ sub: string;
138
+ email?: string | undefined;
139
+ } | null;
140
+ }, {
141
+ [x: string]: unknown;
142
+ sub: string;
143
+ email: string;
144
+ email_verified: boolean;
145
+ "https://willy.im/permissions": string[];
146
+ "https://willy.im/workspaces": {
147
+ [x: string]: unknown;
148
+ id: string;
149
+ slug: string;
150
+ name: string;
151
+ domain: string | null;
152
+ role: string;
153
+ }[];
154
+ name?: string | null | undefined;
155
+ picture?: string | null | undefined;
156
+ image?: string | null | undefined;
157
+ act?: {
158
+ [x: string]: unknown;
159
+ sub: string;
160
+ email?: string | undefined;
161
+ } | null | undefined;
162
+ }>>;
163
+ export type Claims = z.output<typeof ClaimsSchema>;
164
+ //# sourceMappingURL=wire.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wire.d.ts","sourceRoot":"","sources":["../../src/wire.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,eAAO,MAAM,iBAAiB,iCAAiC,CAAA;AAC/D,eAAO,MAAM,gBAAgB,gCAAgC,CAAA;AAE7D,2EAA2E;AAC3E,eAAO,MAAM,eAAe;;;;;;;IAO1B;;;OAGG;;iBAEH,CAAA;AAEF,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,eAAe,CAAC,CAAA;AAExD,+EAA+E;AAC/E,eAAO,MAAM,YAAY;;;;;;;;;;IAYrB,iEAAiE;;;;;;;;;;;;;GAKhE,CAAA;AAEL,MAAM,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,YAAY,CAAC,CAAA;AAElD,kFAAkF;AAClF,eAAO,MAAM,eAAe;;;;;;iBAM1B,CAAA;AAEF,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,eAAe,CAAC,CAAA;AAExD;;;GAGG;AACH,eAAO,MAAM,WAAW;;;iBAGtB,CAAA;AAEF,MAAM,MAAM,KAAK,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,WAAW,CAAC,CAAA;AAMhD;;;;GAIG;AACH,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;IAkBrB,6EAA6E;;IAE7E,gFAAgF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAG/E,CAAA;AAEL,MAAM,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,YAAY,CAAC,CAAA"}
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The OIDC wire, as zod schemas: discovery, the token endpoint, and the claim
3
+ * set. These are not in `./schemas` with the management API because they are
4
+ * standards-defined — nothing generates them, and they never appear in our
5
+ * OpenAPI document.
6
+ *
7
+ * Two different postures live here on purpose:
8
+ *
9
+ * - **Strict** on anything we act on. Without `token_endpoint` there is no
10
+ * flow to run, and an `access_token` that is absent must not become `""`
11
+ * and get written to a session row.
12
+ * - **Tolerant** on claims. A user with no permissions, no workspaces, or no
13
+ * display name is ordinary, not an error — those degrade to empty. Only
14
+ * `sub` is genuinely required, since it is the identity.
15
+ *
16
+ * Unknown fields pass through everywhere: an IdP is allowed to grow.
17
+ */
18
+ import { z } from "zod";
19
+ export const PERMISSIONS_CLAIM = "https://willy.im/permissions";
20
+ export const WORKSPACES_CLAIM = "https://willy.im/workspaces";
21
+ /** The subset of the discovery document we use, plus the fields we may. */
22
+ export const DiscoverySchema = z.looseObject({
23
+ issuer: z.string(),
24
+ authorization_endpoint: z.string().url(),
25
+ token_endpoint: z.string().url(),
26
+ userinfo_endpoint: z.string().url(),
27
+ end_session_endpoint: z.string().url().optional(),
28
+ jwks_uri: z.string().url().optional(),
29
+ /**
30
+ * Per-app session ceiling, in seconds. Not standard OIDC — an IdP extension
31
+ * the SDK clamps `session.expiresIn` against when present.
32
+ */
33
+ session_max_age: z.number().optional(),
34
+ });
35
+ /** Token-endpoint output, camelCased so app code never sees the wire shape. */
36
+ export const TokensSchema = z
37
+ .looseObject({
38
+ access_token: z.string().min(1),
39
+ token_type: z.string().default("Bearer"),
40
+ expires_in: z.number().nullish(),
41
+ refresh_token: z.string().nullish(),
42
+ id_token: z.string().nullish(),
43
+ scope: z.string().nullish(),
44
+ })
45
+ .transform((raw) => ({
46
+ accessToken: raw.access_token,
47
+ tokenType: raw.token_type,
48
+ /** Seconds until the access token expires, when the IdP says. */
49
+ expiresIn: raw.expires_in ?? null,
50
+ refreshToken: raw.refresh_token ?? null,
51
+ idToken: raw.id_token ?? null,
52
+ scope: raw.scope ?? null,
53
+ }));
54
+ /** A tenant inside THIS app. `domain` is set for multi-domain apps, else null. */
55
+ export const WorkspaceSchema = z.looseObject({
56
+ id: z.string(),
57
+ slug: z.string().default(""),
58
+ name: z.string().default(""),
59
+ domain: z.string().nullish().transform((value) => value || null),
60
+ role: z.string().default("member"),
61
+ });
62
+ /**
63
+ * The RFC 8693 `act` claim: present while an IdP admin is impersonating the
64
+ * user. Audit-only — tag your logs with it, never branch authorization on it.
65
+ */
66
+ export const ActorSchema = z.looseObject({
67
+ sub: z.string().min(1),
68
+ email: z.string().optional(),
69
+ });
70
+ /** `.catch` per field, so one bad element degrades instead of failing a login. */
71
+ const tolerantArray = (item) => z.array(item.nullable().catch(null)).catch([]).transform((values) => values.filter((v) => v !== null));
72
+ /**
73
+ * Wire claims -> `Claims`. The `https://willy.im/*` namespace is unwrapped
74
+ * here: namespaced URI claims are an OIDC requirement, not something app code
75
+ * should ever have to type.
76
+ */
77
+ export const ClaimsSchema = z
78
+ .looseObject({
79
+ sub: z.string().min(1),
80
+ email: z.string().catch(""),
81
+ email_verified: z.boolean().catch(false),
82
+ name: z.string().nullish().catch(null),
83
+ picture: z.string().nullish().catch(null),
84
+ image: z.string().nullish().catch(null),
85
+ [PERMISSIONS_CLAIM]: tolerantArray(z.string()),
86
+ [WORKSPACES_CLAIM]: tolerantArray(WorkspaceSchema),
87
+ act: ActorSchema.nullish().catch(null),
88
+ })
89
+ .transform((raw) => ({
90
+ sub: raw.sub,
91
+ email: raw.email,
92
+ emailVerified: raw.email_verified,
93
+ name: raw.name || null,
94
+ image: raw.picture || raw.image || null,
95
+ /** Product permissions granted in this app. Unwrapped from the namespace. */
96
+ permissions: raw[PERMISSIONS_CLAIM],
97
+ /** Workspaces the user belongs to in this app. Unwrapped from the namespace. */
98
+ workspaces: raw[WORKSPACES_CLAIM],
99
+ actor: raw.act ?? null,
100
+ }));
@@ -23,6 +23,7 @@
23
23
  "/api/v1/applications": {
24
24
  "get": {
25
25
  "summary": "List registered applications",
26
+ "description": "Requires the superadmin token.",
26
27
  "security": [
27
28
  {
28
29
  "bearerAuth": []
@@ -109,6 +110,7 @@
109
110
  "/api/v1/users": {
110
111
  "get": {
111
112
  "summary": "List users",
113
+ "description": "Requires the superadmin token.",
112
114
  "security": [
113
115
  {
114
116
  "bearerAuth": []
@@ -179,6 +181,7 @@
179
181
  "/api/v1/workspaces": {
180
182
  "get": {
181
183
  "summary": "List workspaces",
184
+ "description": "Requires the superadmin token.",
182
185
  "security": [
183
186
  {
184
187
  "bearerAuth": []
@@ -592,9 +595,6 @@
592
595
  },
593
596
  "409": {
594
597
  "description": "Conflict (already a member, last admin, slug taken, …)"
595
- },
596
- "422": {
597
- "description": "Body failed validation"
598
598
  }
599
599
  }
600
600
  }
@@ -1226,9 +1226,6 @@
1226
1226
  },
1227
1227
  "409": {
1228
1228
  "description": "Conflict (already a member, last admin, slug taken, …)"
1229
- },
1230
- "422": {
1231
- "description": "Body failed validation"
1232
1229
  }
1233
1230
  }
1234
1231
  }