@willyim/idp 0.3.1 → 0.5.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 CHANGED
@@ -323,6 +323,55 @@ ingest token, say — identifies a site rather than a user, cannot be kept secre
323
323
  and must not pay a round trip per hit. Keep those in the app's own table and
324
324
  gate them on `Origin` plus rate limiting.
325
325
 
326
+ ## Linked identities
327
+
328
+ A user's ids on *other* systems — their Slack member id, their WhatsApp number,
329
+ a Telegram id — pinned to their IdP user. The point is that an app hearing from
330
+ someone on Slack asks the IdP "who is this, and what may they do here?" and gets
331
+ the same answer a browser session for that person would carry. The app keeps no
332
+ table of Slack ids: the one it would write is exactly the allowlist the IdP
333
+ exists to replace.
334
+
335
+ Linking is **superadmin-only** — a link asserts identity with nothing to prove
336
+ it, so no app and no member may do it:
337
+
338
+ ```sh
339
+ curl -X POST https://idp.willy.im/api/v1/users/<userId>/identities \
340
+ -H "authorization: Bearer wim_<admin key>" -H "content-type: application/json" \
341
+ -d '{"provider":"slack","externalId":"U0AAE7LAATD","label":"house workspace"}'
342
+ ```
343
+
344
+ Resolving is app-scoped and needs `identity:resolve` on the app's own key:
345
+
346
+ ```ts
347
+ import { createIdentities, grants } from "@willyim/idp"
348
+
349
+ const identities = createIdentities({
350
+ baseUrl: "https://idp.willy.im",
351
+ token: env.IDP_MANAGEMENT_KEY, // the app's wim_… key, with identity:resolve
352
+ app: "bender",
353
+ })
354
+
355
+ // On every inbound Slack message:
356
+ const who = await identities.resolve("slack", event.user)
357
+ if (!who.found) return // store it, do not answer
358
+ if (!grants(who.permissions, "chat:respond")) return // they exist, this app never granted them
359
+ who.userId // the IdP user — the same id a session or a wak_ key would carry
360
+ ```
361
+
362
+ `permissions` are the user's product permissions for **the asking app**,
363
+ computed by the same code the claims hook runs at token mint: an admin member
364
+ gets the whole catalog, a plain member gets their grants, a linked user with no
365
+ membership resolves as `found: true` with none. `found: false` is a miss, not an
366
+ error, and is the common case in any shared channel.
367
+
368
+ Verdicts are cached by `(provider, externalId)` — 60s for a hit and for a miss,
369
+ both tunable via `cache` — and concurrent lookups of the same pair share one
370
+ round trip. A failed round trip is never cached. The miss TTL bounds how fast a
371
+ *new* link takes effect; call `forget(provider, externalId)` after one you made
372
+ yourself. The provider is case-insensitive; the id is exact, as the other
373
+ system spells it.
374
+
326
375
  ## Management API types
327
376
 
328
377
  Endpoints without sugar of their own go through `createManagementApi`, whose
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Linked identities, from the consuming app's side: "someone just messaged me
3
+ * on Slack as U0AAE7LAATD — who is that, and what may they do here?"
4
+ *
5
+ * The IdP is the only place the answer lives. A user's ids on other systems
6
+ * are pinned to their IdP user by a superadmin (the management API's
7
+ * `/users/{userId}/identities`), and an app resolves them with its own scoped
8
+ * `wim_…` key. The app never keeps a table of Slack ids — the one it would
9
+ * write is exactly the allowlist the IdP exists to replace.
10
+ *
11
+ * `permissions` in the answer are the user's product permissions for THIS app,
12
+ * computed the same way the claims hook computes them at token mint, so a
13
+ * Slack message and a browser session from the same person carry the same
14
+ * grants. Enforcement stays the app's job, same as everywhere else; `grants()`
15
+ * from `./claims.js` is the matcher to use.
16
+ *
17
+ * Resolution is a network round trip on a hot path — every inbound chat
18
+ * message — so verdicts are cached by (provider, externalId) with a short TTL,
19
+ * and concurrent lookups of the same pair share one request. A miss is cached
20
+ * too (most messages in any shared channel are from people who are not
21
+ * linked), which bounds how quickly a NEW link takes effect: `cache.missTtlMs`.
22
+ * A failed round trip is never cached, so an IdP blip does not lock anyone out
23
+ * for the whole TTL. `forget()` after a link you performed yourself.
24
+ */
25
+ import type { z } from "zod";
26
+ import { type ManagementApiOptions } from "./api.js";
27
+ import type { IdentityResolutionSchema } from "./schemas/index.js";
28
+ /** The IdP's answer. `found: false` is data, not an error. */
29
+ export type IdentityResolution = z.output<typeof IdentityResolutionSchema>;
30
+ /** The `found: true` half. */
31
+ export type ResolvedIdentity = Extract<IdentityResolution, {
32
+ found: true;
33
+ }>;
34
+ export type IdentityCacheOptions = {
35
+ /** How long a `found: true` verdict is reused. Default 60s. */
36
+ ttlMs?: number;
37
+ /** How long a `found: false` verdict is reused. Default 60s — see the header. */
38
+ missTtlMs?: number;
39
+ /** Entry ceiling before the oldest are dropped. Default 1000. */
40
+ max?: number;
41
+ };
42
+ export type IdentitiesOptions = ManagementApiOptions & {
43
+ /** The app the permissions in each answer are scoped to. */
44
+ app: string;
45
+ /** `false` disables caching entirely (every resolve is a round trip). */
46
+ cache?: IdentityCacheOptions | false;
47
+ /** Clock seam, for tests. */
48
+ now?: () => number;
49
+ };
50
+ export declare function createIdentities(options: IdentitiesOptions): {
51
+ resolve: (provider: string, externalId: string, init?: {
52
+ signal?: AbortSignal;
53
+ fresh?: boolean;
54
+ }) => Promise<IdentityResolution>;
55
+ /** Drops one pair's cached verdict, or the whole cache when called bare. */
56
+ forget(provider?: string, externalId?: string): void;
57
+ };
58
+ export type Identities = ReturnType<typeof createIdentities>;
59
+ //# sourceMappingURL=identities.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identities.d.ts","sourceRoot":"","sources":["../../src/identities.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAE5B,OAAO,EAAuB,KAAK,oBAAoB,EAAE,MAAM,UAAU,CAAA;AACzE,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,oBAAoB,CAAA;AAElE,8DAA8D;AAC9D,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,wBAAwB,CAAC,CAAA;AAE1E,8BAA8B;AAC9B,MAAM,MAAM,gBAAgB,GAAG,OAAO,CAAC,kBAAkB,EAAE;IAAE,KAAK,EAAE,IAAI,CAAA;CAAE,CAAC,CAAA;AAE3E,MAAM,MAAM,oBAAoB,GAAG;IACjC,+DAA+D;IAC/D,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,iEAAiE;IACjE,GAAG,CAAC,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG,oBAAoB,GAAG;IACrD,4DAA4D;IAC5D,GAAG,EAAE,MAAM,CAAA;IACX,yEAAyE;IACzE,KAAK,CAAC,EAAE,oBAAoB,GAAG,KAAK,CAAA;IACpC,6BAA6B;IAC7B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB,CAAA;AAQD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,iBAAiB;wBAiC7C,MAAM,cACJ,MAAM,SACZ;QAAE,MAAM,CAAC,EAAE,WAAW,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,KAC9C,OAAO,CAAC,kBAAkB,CAAC;IAgC5B,4EAA4E;sBAC1D,MAAM,eAAe,MAAM,GAAG,IAAI;EAQvD;AAED,MAAM,MAAM,UAAU,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAA"}
Binary file
@@ -18,6 +18,8 @@ export { memorySessions, type MemorySessionStore, type SessionRecord, type Sessi
18
18
  export { grants, normalizeClaims, PERMISSIONS_CLAIM, WORKSPACES_CLAIM, type Actor, type Claims, type Workspace, } from "./claims.js";
19
19
  export { createManagementApi, type ManagementApi, type ManagementApiOptions, } from "./api.js";
20
20
  export { createUserKeys, readApiKey, type AuthenticatedKey, type AuthenticateOptions, type AuthenticateResult, type CreateUserApiKeyInput, type ListFilter, type MintedUserApiKey, type UserApiKey, type UserKeyCacheOptions, type UserKeys, type UserKeysOptions, type UserKeyValidation, } from "./user-keys.js";
21
+ export { createIdentities, type Identities, type IdentitiesOptions, type IdentityCacheOptions, type ResolvedIdentity, type IdentityResolution, } from "./identities.js";
22
+ export { createResourceServer, type ResourceServer, type ResourceServerOptions, type VerifiedAccessToken, type AuthResult, } from "./resource-server.js";
21
23
  export { parseDuration, type Duration } from "./duration.js";
22
24
  export { clearCookie, parseCookies, readCookie, serializeCookie, type CookieOptions, } from "./cookie.js";
23
25
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EACL,eAAe,EACf,UAAU,EACV,cAAc,EACd,QAAQ,EACR,gBAAgB,EAChB,KAAK,qBAAqB,EAC1B,KAAK,SAAS,EACd,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,QAAQ,EACb,KAAK,MAAM,GACZ,MAAM,aAAa,CAAA;AAEpB,OAAO,EACL,SAAS,EACT,sBAAsB,EACtB,QAAQ,EACR,KAAK,GAAG,EACR,KAAK,UAAU,EACf,KAAK,OAAO,EACZ,KAAK,cAAc,GACpB,MAAM,cAAc,CAAA;AAErB,OAAO,EACL,cAAc,EACd,KAAK,kBAAkB,EACvB,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,YAAY,CAAA;AAEnB,OAAO,EACL,MAAM,EACN,eAAe,EACf,iBAAiB,EACjB,gBAAgB,EAChB,KAAK,KAAK,EACV,KAAK,MAAM,EACX,KAAK,SAAS,GACf,MAAM,aAAa,CAAA;AAEpB,OAAO,EACL,mBAAmB,EACnB,KAAK,aAAa,EAClB,KAAK,oBAAoB,GAC1B,MAAM,UAAU,CAAA;AAEjB,OAAO,EACL,cAAc,EACd,UAAU,EACV,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EACxB,KAAK,kBAAkB,EACvB,KAAK,qBAAqB,EAC1B,KAAK,UAAU,EACf,KAAK,gBAAgB,EACrB,KAAK,UAAU,EACf,KAAK,mBAAmB,EACxB,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,KAAK,iBAAiB,GACvB,MAAM,gBAAgB,CAAA;AAEvB,OAAO,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAA;AAC5D,OAAO,EACL,WAAW,EACX,YAAY,EACZ,UAAU,EACV,eAAe,EACf,KAAK,aAAa,GACnB,MAAM,aAAa,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EACL,eAAe,EACf,UAAU,EACV,cAAc,EACd,QAAQ,EACR,gBAAgB,EAChB,KAAK,qBAAqB,EAC1B,KAAK,SAAS,EACd,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,QAAQ,EACb,KAAK,MAAM,GACZ,MAAM,aAAa,CAAA;AAEpB,OAAO,EACL,SAAS,EACT,sBAAsB,EACtB,QAAQ,EACR,KAAK,GAAG,EACR,KAAK,UAAU,EACf,KAAK,OAAO,EACZ,KAAK,cAAc,GACpB,MAAM,cAAc,CAAA;AAErB,OAAO,EACL,cAAc,EACd,KAAK,kBAAkB,EACvB,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,YAAY,CAAA;AAEnB,OAAO,EACL,MAAM,EACN,eAAe,EACf,iBAAiB,EACjB,gBAAgB,EAChB,KAAK,KAAK,EACV,KAAK,MAAM,EACX,KAAK,SAAS,GACf,MAAM,aAAa,CAAA;AAEpB,OAAO,EACL,mBAAmB,EACnB,KAAK,aAAa,EAClB,KAAK,oBAAoB,GAC1B,MAAM,UAAU,CAAA;AAEjB,OAAO,EACL,cAAc,EACd,UAAU,EACV,KAAK,gBAAgB,EACrB,KAAK,mBAAmB,EACxB,KAAK,kBAAkB,EACvB,KAAK,qBAAqB,EAC1B,KAAK,UAAU,EACf,KAAK,gBAAgB,EACrB,KAAK,UAAU,EACf,KAAK,mBAAmB,EACxB,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,KAAK,iBAAiB,GACvB,MAAM,gBAAgB,CAAA;AAEvB,OAAO,EACL,gBAAgB,EAChB,KAAK,UAAU,EACf,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,iBAAiB,CAAA;AAExB,OAAO,EACL,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,mBAAmB,EACxB,KAAK,UAAU,GAChB,MAAM,sBAAsB,CAAA;AAE7B,OAAO,EAAE,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,eAAe,CAAA;AAC5D,OAAO,EACL,WAAW,EACX,YAAY,EACZ,UAAU,EACV,eAAe,EACf,KAAK,aAAa,GACnB,MAAM,aAAa,CAAA"}
package/dist/src/index.js CHANGED
@@ -18,5 +18,7 @@ export { memorySessions, } from "./store.js";
18
18
  export { grants, normalizeClaims, PERMISSIONS_CLAIM, WORKSPACES_CLAIM, } from "./claims.js";
19
19
  export { createManagementApi, } from "./api.js";
20
20
  export { createUserKeys, readApiKey, } from "./user-keys.js";
21
+ export { createIdentities, } from "./identities.js";
22
+ export { createResourceServer, } from "./resource-server.js";
21
23
  export { parseDuration } from "./duration.js";
22
24
  export { clearCookie, parseCookies, readCookie, serializeCookie, } from "./cookie.js";
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Being an OAuth 2.1 resource server against the willy.im IdP — which is what
3
+ * an MCP server is.
4
+ *
5
+ * The MCP authorization spec is plain OAuth with three discovery hops, and
6
+ * every one of them is either served by the IdP already or is a static
7
+ * document this module writes for you:
8
+ *
9
+ * 1. The client hits the resource without a token and gets a 401 carrying
10
+ * `WWW-Authenticate: Bearer resource_metadata="…"` → `challenge()`
11
+ * 2. It fetches that URL, the Protected Resource Metadata (RFC 9728), which
12
+ * names the authorization server → `metadata()`
13
+ * 3. It fetches the AS metadata (RFC 8414) from the IdP, registers itself
14
+ * (RFC 7591), runs authorization code + PKCE with `resource=` (RFC 8707),
15
+ * and comes back with a JWT access token whose `aud` is this resource.
16
+ * 4. The resource server verifies that JWT against the IdP's JWKS and reads
17
+ * the permissions claim → `authenticate()`
18
+ *
19
+ * Step 4 is the only one with any real code, and it is deliberately small:
20
+ * signature (EdDSA/Ed25519, ES256 or RS256 via WebCrypto — no jose, no deps),
21
+ * issuer, audience, time. The permissions on the token were computed by the
22
+ * IdP for the app that owns this resource (the same way a browser session's
23
+ * are), so the app looks nothing up — it reads a claim and matches it with
24
+ * `grants()` like everywhere else.
25
+ *
26
+ * The JWKS is cached and refetched once on an unknown `kid`, which is how a key
27
+ * rotation at the IdP is picked up without a restart.
28
+ */
29
+ export declare const PERMISSIONS_CLAIM = "https://willy.im/permissions";
30
+ export declare const APP_CLAIM = "https://willy.im/app";
31
+ export type ResourceServerOptions = {
32
+ /** The IdP's OAuth issuer — `https://idp.willy.im/auth` (note the /auth). */
33
+ issuer: string;
34
+ /**
35
+ * This server's canonical URI, exactly as registered on the application in
36
+ * the IdP (`resources`) — e.g. `https://bender.romo.fyi/mcp`. It is the
37
+ * audience a token must carry, compared byte for byte.
38
+ */
39
+ resource: string;
40
+ /** Defaults to `${issuer}/jwks`. */
41
+ jwksUrl?: string;
42
+ /** Advertised in the metadata document. Informational only. */
43
+ scopesSupported?: string[];
44
+ /** How long a fetched JWKS is reused. Default 10 minutes. */
45
+ jwksTtlMs?: number;
46
+ /** Clock skew tolerated on exp/nbf. Default 60s. */
47
+ leewaySeconds?: number;
48
+ fetch?: typeof fetch;
49
+ /** Clock seam, for tests. */
50
+ now?: () => number;
51
+ };
52
+ /** A verified access token, reduced to what an app acts on. */
53
+ export type VerifiedAccessToken = {
54
+ /** The IdP user id — the same `sub` a session or a wak_ key resolves to. */
55
+ sub: string;
56
+ /** The app the permissions are for (the owner of `resource`), when present. */
57
+ app: string | null;
58
+ /** The user's product permissions for that app. Match with `has()`/`grants()`. */
59
+ permissions: string[];
60
+ /** Space-delimited OAuth scopes on the token (openid/profile/…), if any. */
61
+ scopes: string[];
62
+ /** Everything else on the token, for a caller that needs a raw claim. */
63
+ claims: Record<string, unknown>;
64
+ };
65
+ export type AuthResult = {
66
+ ok: true;
67
+ token: VerifiedAccessToken;
68
+ } | {
69
+ ok: false;
70
+ status: 401 | 403;
71
+ error: string;
72
+ description: string;
73
+ };
74
+ export declare function createResourceServer(options: ResourceServerOptions): {
75
+ verify: (token: string) => Promise<AuthResult>;
76
+ authenticate: (request: {
77
+ headers: {
78
+ get(name: string): string | null;
79
+ };
80
+ }, init?: {
81
+ permissions?: string[];
82
+ }) => Promise<AuthResult>;
83
+ challenge: (error?: {
84
+ error: string;
85
+ description: string;
86
+ }) => string;
87
+ metadata: () => Record<string, unknown>;
88
+ };
89
+ export type ResourceServer = ReturnType<typeof createResourceServer>;
90
+ //# sourceMappingURL=resource-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resource-server.d.ts","sourceRoot":"","sources":["../../src/resource-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAIH,eAAO,MAAM,iBAAiB,iCAAiC,CAAA;AAC/D,eAAO,MAAM,SAAS,yBAAyB,CAAA;AAE/C,MAAM,MAAM,qBAAqB,GAAG;IAClC,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAA;IACd;;;;OAIG;IACH,QAAQ,EAAE,MAAM,CAAA;IAChB,oCAAoC;IACpC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,+DAA+D;IAC/D,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IAC1B,6DAA6D;IAC7D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,oDAAoD;IACpD,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;IACpB,6BAA6B;IAC7B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB,CAAA;AAED,+DAA+D;AAC/D,MAAM,MAAM,mBAAmB,GAAG;IAChC,4EAA4E;IAC5E,GAAG,EAAE,MAAM,CAAA;IACX,+EAA+E;IAC/E,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,kFAAkF;IAClF,WAAW,EAAE,MAAM,EAAE,CAAA;IACrB,4EAA4E;IAC5E,MAAM,EAAE,MAAM,EAAE,CAAA;IAChB,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAChC,CAAA;AAED,MAAM,MAAM,UAAU,GAClB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,mBAAmB,CAAA;CAAE,GACxC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,GAAG,GAAG,GAAG,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAAA;AAgDxE,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,qBAAqB;oBAiDpC,MAAM,KAAG,OAAO,CAAC,UAAU,CAAC;4BA8D9C;QAAE,OAAO,EAAE;YAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;SAAE,CAAA;KAAE,SACpD;QAAE,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,KAC/B,OAAO,CAAC,UAAU,CAAC;wBAwBK;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,KAAG,MAAM;oBAarD,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;EAU7C;AAED,MAAM,MAAM,cAAc,GAAG,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAA"}
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Being an OAuth 2.1 resource server against the willy.im IdP — which is what
3
+ * an MCP server is.
4
+ *
5
+ * The MCP authorization spec is plain OAuth with three discovery hops, and
6
+ * every one of them is either served by the IdP already or is a static
7
+ * document this module writes for you:
8
+ *
9
+ * 1. The client hits the resource without a token and gets a 401 carrying
10
+ * `WWW-Authenticate: Bearer resource_metadata="…"` → `challenge()`
11
+ * 2. It fetches that URL, the Protected Resource Metadata (RFC 9728), which
12
+ * names the authorization server → `metadata()`
13
+ * 3. It fetches the AS metadata (RFC 8414) from the IdP, registers itself
14
+ * (RFC 7591), runs authorization code + PKCE with `resource=` (RFC 8707),
15
+ * and comes back with a JWT access token whose `aud` is this resource.
16
+ * 4. The resource server verifies that JWT against the IdP's JWKS and reads
17
+ * the permissions claim → `authenticate()`
18
+ *
19
+ * Step 4 is the only one with any real code, and it is deliberately small:
20
+ * signature (EdDSA/Ed25519, ES256 or RS256 via WebCrypto — no jose, no deps),
21
+ * issuer, audience, time. The permissions on the token were computed by the
22
+ * IdP for the app that owns this resource (the same way a browser session's
23
+ * are), so the app looks nothing up — it reads a claim and matches it with
24
+ * `grants()` like everywhere else.
25
+ *
26
+ * The JWKS is cached and refetched once on an unknown `kid`, which is how a key
27
+ * rotation at the IdP is picked up without a restart.
28
+ */
29
+ import { grants } from "./claims.js";
30
+ export const PERMISSIONS_CLAIM = "https://willy.im/permissions";
31
+ export const APP_CLAIM = "https://willy.im/app";
32
+ const DEFAULT_JWKS_TTL_MS = 600_000;
33
+ const DEFAULT_LEEWAY_S = 60;
34
+ function b64urlToBytes(s) {
35
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
36
+ const b = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
37
+ const out = new Uint8Array(b.length);
38
+ for (let i = 0; i < b.length; i++)
39
+ out[i] = b.charCodeAt(i);
40
+ return out;
41
+ }
42
+ function decodeJson(segment) {
43
+ return JSON.parse(new TextDecoder().decode(b64urlToBytes(segment)));
44
+ }
45
+ /** The WebCrypto import + verify params for the algorithms the IdP may sign with. */
46
+ function algParams(alg, jwk) {
47
+ switch (alg) {
48
+ case "EdDSA":
49
+ return { importAlg: { name: "Ed25519" }, verifyAlg: { name: "Ed25519" } };
50
+ case "ES256":
51
+ return { importAlg: { name: "ECDSA", namedCurve: "P-256" }, verifyAlg: { name: "ECDSA", hash: "SHA-256" } };
52
+ case "RS256":
53
+ return {
54
+ importAlg: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
55
+ verifyAlg: { name: "RSASSA-PKCS1-v1_5" },
56
+ };
57
+ default:
58
+ return jwk.kty ? null : null;
59
+ }
60
+ }
61
+ export function createResourceServer(options) {
62
+ const doFetch = options.fetch ?? fetch;
63
+ const now = options.now ?? (() => Date.now());
64
+ const jwksUrl = options.jwksUrl ?? `${options.issuer.replace(/\/$/, "")}/jwks`;
65
+ const jwksTtlMs = options.jwksTtlMs ?? DEFAULT_JWKS_TTL_MS;
66
+ const leeway = (options.leewaySeconds ?? DEFAULT_LEEWAY_S) * 1000;
67
+ let jwks = null;
68
+ async function loadJwks(force) {
69
+ if (!force && jwks && now() - jwks.at < jwksTtlMs)
70
+ return jwks.keys;
71
+ const res = await doFetch(jwksUrl);
72
+ if (!res.ok)
73
+ throw new Error(`jwks fetch failed: ${res.status}`);
74
+ const body = (await res.json());
75
+ jwks = { at: now(), keys: body.keys ?? [] };
76
+ return jwks.keys;
77
+ }
78
+ /** Find the signing key by kid, refetching ONCE on a miss to catch rotation. */
79
+ async function keyFor(kid) {
80
+ const pick = (keys) => keys.find((k) => (kid ? k.kid === kid : true)) ?? null;
81
+ let key = pick(await loadJwks(false));
82
+ if (!key)
83
+ key = pick(await loadJwks(true));
84
+ return key;
85
+ }
86
+ async function verifySignature(header, signingInput, signature) {
87
+ const jwk = await keyFor(header.kid);
88
+ if (!jwk)
89
+ return false;
90
+ const params = algParams(header.alg, jwk);
91
+ if (!params)
92
+ return false;
93
+ const key = await crypto.subtle.importKey("jwk", jwk, params.importAlg, false, ["verify"]);
94
+ return crypto.subtle.verify(params.verifyAlg, key, signature, new TextEncoder().encode(signingInput));
95
+ }
96
+ /**
97
+ * Verifies a raw bearer token. Returns a discriminated result rather than
98
+ * throwing, so the caller owns the response shape. `insufficient_scope` is a
99
+ * 403; everything about the token being absent or bad is a 401.
100
+ */
101
+ async function verify(token) {
102
+ const parts = token.split(".");
103
+ if (parts.length !== 3) {
104
+ return { ok: false, status: 401, error: "invalid_token", description: "Not a JWT." };
105
+ }
106
+ let header;
107
+ let claims;
108
+ try {
109
+ header = decodeJson(parts[0]);
110
+ claims = decodeJson(parts[1]);
111
+ }
112
+ catch {
113
+ return { ok: false, status: 401, error: "invalid_token", description: "Malformed JWT." };
114
+ }
115
+ let valid = false;
116
+ try {
117
+ valid = await verifySignature(header, `${parts[0]}.${parts[1]}`, b64urlToBytes(parts[2]));
118
+ }
119
+ catch {
120
+ valid = false;
121
+ }
122
+ if (!valid) {
123
+ return { ok: false, status: 401, error: "invalid_token", description: "Bad signature." };
124
+ }
125
+ if (claims.iss !== options.issuer) {
126
+ return { ok: false, status: 401, error: "invalid_token", description: "Wrong issuer." };
127
+ }
128
+ const aud = claims.aud;
129
+ const audOk = Array.isArray(aud) ? aud.includes(options.resource) : aud === options.resource;
130
+ if (!audOk) {
131
+ return { ok: false, status: 401, error: "invalid_token", description: "Token is for a different resource." };
132
+ }
133
+ const t = now();
134
+ if (typeof claims.exp === "number" && t > claims.exp * 1000 + leeway) {
135
+ return { ok: false, status: 401, error: "invalid_token", description: "Token expired." };
136
+ }
137
+ if (typeof claims.nbf === "number" && t < claims.nbf * 1000 - leeway) {
138
+ return { ok: false, status: 401, error: "invalid_token", description: "Token not yet valid." };
139
+ }
140
+ const permissions = Array.isArray(claims[PERMISSIONS_CLAIM])
141
+ ? claims[PERMISSIONS_CLAIM].filter((p) => typeof p === "string")
142
+ : [];
143
+ const scopes = typeof claims.scope === "string" ? claims.scope.split(" ").filter(Boolean) : [];
144
+ return {
145
+ ok: true,
146
+ token: {
147
+ sub: String(claims.sub ?? ""),
148
+ app: typeof claims[APP_CLAIM] === "string" ? claims[APP_CLAIM] : null,
149
+ permissions,
150
+ scopes,
151
+ claims,
152
+ },
153
+ };
154
+ }
155
+ /**
156
+ * The whole check off a `Request`: pull the bearer, verify it, and confirm
157
+ * every required permission (wildcard-aware via `grants()`). Returns a
158
+ * result rather than throwing.
159
+ */
160
+ async function authenticate(request, init = {}) {
161
+ const authz = request.headers.get("authorization");
162
+ const bearer = authz && /^Bearer\s+(.+)$/i.exec(authz.trim())?.[1];
163
+ if (!bearer) {
164
+ return { ok: false, status: 401, error: "invalid_token", description: "No bearer token." };
165
+ }
166
+ const verified = await verify(bearer.trim());
167
+ if (!verified.ok)
168
+ return verified;
169
+ const missing = (init.permissions ?? []).filter((p) => !grants(verified.token.permissions, p));
170
+ if (missing.length) {
171
+ return {
172
+ ok: false,
173
+ status: 403,
174
+ error: "insufficient_scope",
175
+ description: `Missing permission(s): ${missing.join(", ")}.`,
176
+ };
177
+ }
178
+ return verified;
179
+ }
180
+ /**
181
+ * The `WWW-Authenticate` value for a 401, pointing a client at the metadata
182
+ * document so it can discover how to get a token (RFC 9728 §5.1).
183
+ */
184
+ function challenge(error) {
185
+ const parts = [
186
+ `Bearer resource_metadata="${options.resource}/.well-known/oauth-protected-resource"`,
187
+ ];
188
+ if (error)
189
+ parts.push(`error="${error.error}"`, `error_description="${error.description}"`);
190
+ return parts.join(", ");
191
+ }
192
+ /**
193
+ * The Protected Resource Metadata document (RFC 9728) — served at
194
+ * `/.well-known/oauth-protected-resource`. It names this resource and the
195
+ * IdP as its authorization server; the client takes it from there.
196
+ */
197
+ function metadata() {
198
+ return {
199
+ resource: options.resource,
200
+ authorization_servers: [options.issuer],
201
+ bearer_methods_supported: ["header"],
202
+ ...(options.scopesSupported ? { scopes_supported: options.scopesSupported } : {}),
203
+ };
204
+ }
205
+ return { verify, authenticate, challenge, metadata };
206
+ }
@@ -16,6 +16,7 @@ export declare const ApplicationSchema: z.ZodObject<{
16
16
  app: z.ZodNullable<z.ZodString>;
17
17
  allowSignup: z.ZodBoolean;
18
18
  permissions: z.ZodArray<z.ZodString>;
19
+ resources: z.ZodArray<z.ZodString>;
19
20
  redirectUris: z.ZodArray<z.ZodString>;
20
21
  disabled: z.ZodBoolean;
21
22
  createdAt: z.ZodString;
@@ -41,6 +42,7 @@ export declare const ApplicationListSchema: z.ZodObject<{
41
42
  app: z.ZodNullable<z.ZodString>;
42
43
  allowSignup: z.ZodBoolean;
43
44
  permissions: z.ZodArray<z.ZodString>;
45
+ resources: z.ZodArray<z.ZodString>;
44
46
  redirectUris: z.ZodArray<z.ZodString>;
45
47
  disabled: z.ZodBoolean;
46
48
  createdAt: z.ZodString;
@@ -66,6 +68,7 @@ export declare const UpdateApplicationInput: z.ZodObject<{
66
68
  name: z.ZodOptional<z.ZodString>;
67
69
  redirectUris: z.ZodOptional<z.ZodArray<z.ZodString>>;
68
70
  allowSignup: z.ZodOptional<z.ZodBoolean>;
71
+ resources: z.ZodOptional<z.ZodArray<z.ZodString>>;
69
72
  }, z.core.$strip>;
70
73
  export declare const ClientSecretSchema: z.ZodObject<{
71
74
  clientSecret: z.ZodString;
@@ -219,6 +222,42 @@ export declare const UserApiKeyValidationSchema: z.ZodUnion<readonly [z.ZodObjec
219
222
  not_found: "not_found";
220
223
  }>;
221
224
  }, z.core.$strip>]>;
225
+ export declare const LinkedIdentitySchema: z.ZodObject<{
226
+ id: z.ZodString;
227
+ userId: z.ZodString;
228
+ provider: z.ZodString;
229
+ externalId: z.ZodString;
230
+ label: z.ZodNullable<z.ZodString>;
231
+ createdAt: z.ZodString;
232
+ }, z.core.$strip>;
233
+ export declare const LinkedIdentityListSchema: z.ZodObject<{
234
+ identities: z.ZodArray<z.ZodObject<{
235
+ id: z.ZodString;
236
+ userId: z.ZodString;
237
+ provider: z.ZodString;
238
+ externalId: z.ZodString;
239
+ label: z.ZodNullable<z.ZodString>;
240
+ createdAt: z.ZodString;
241
+ }, z.core.$strip>>;
242
+ }, z.core.$strip>;
243
+ export declare const LinkIdentityInput: z.ZodObject<{
244
+ provider: z.ZodString;
245
+ externalId: z.ZodString;
246
+ label: z.ZodOptional<z.ZodString>;
247
+ }, z.core.$strip>;
248
+ export declare const LinkedIdentityCreatedSchema: z.ZodObject<{
249
+ id: z.ZodString;
250
+ created: z.ZodBoolean;
251
+ }, z.core.$strip>;
252
+ export declare const IdentityResolutionSchema: z.ZodUnion<readonly [z.ZodObject<{
253
+ found: z.ZodLiteral<true>;
254
+ userId: z.ZodString;
255
+ email: z.ZodString;
256
+ name: z.ZodNullable<z.ZodString>;
257
+ permissions: z.ZodArray<z.ZodString>;
258
+ }, z.core.$strip>, z.ZodObject<{
259
+ found: z.ZodLiteral<false>;
260
+ }, z.core.$strip>]>;
222
261
  export declare const AuditEntrySchema: z.ZodObject<{
223
262
  id: z.ZodNumber;
224
263
  tableName: z.ZodString;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/schemas/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,eAAO,MAAM,iBAAiB;;;;;;;;;iBAS5B,CAAA;AAEF,eAAO,MAAM,UAAU;;;;;;iBAMrB,CAAA;AAEF,eAAO,MAAM,eAAe;;;;;;iBAM1B,CAAA;AAEF,eAAO,MAAM,qBAAqB;;;;;;;;;;;iBAAyD,CAAA;AAE3F;;;GAGG;AACH,eAAO,MAAM,sBAAsB;;;;;iBAejC,CAAA;AACF,eAAO,MAAM,wBAAwB;;;;iBAInC,CAAA;AAEF,uEAAuE;AACvE,eAAO,MAAM,sBAAsB;;;;iBAIjC,CAAA;AAEF,eAAO,MAAM,kBAAkB;;iBAE7B,CAAA;AAEF,+DAA+D;AAC/D,eAAO,MAAM,sBAAsB;;iBAEjC,CAAA;AACF,eAAO,MAAM,oBAAoB;;iBAAiD,CAAA;AAClF,eAAO,MAAM,cAAc;;;;;;;;iBAA2C,CAAA;AACtE,eAAO,MAAM,mBAAmB;;;;;;;;iBAAqD,CAAA;AAIrF,eAAO,MAAM,UAAU;;;EAA8B,CAAA;AAErD,eAAO,MAAM,YAAY;;;;;;;;;iBAMvB,CAAA;AACF,eAAO,MAAM,gBAAgB;;;;;;;;;;;iBAA+C,CAAA;AAE5E,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB;;;;;;;iBAI5B,CAAA;AACF,eAAO,MAAM,kBAAkB;;;;;iBAG7B,CAAA;AAEF,eAAO,MAAM,iBAAiB;;;;;;iBAG5B,CAAA;AAEF,eAAO,MAAM,oBAAoB;;;iBAM/B,CAAA;AACF,eAAO,MAAM,sBAAsB;;;;iBAIjC,CAAA;AAEF,eAAO,MAAM,QAAQ;;iBAAoC,CAAA;AAIzD,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;iBAW3B,CAAA;AACF,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;iBAAgD,CAAA;AAEjF,eAAO,MAAM,qBAAqB;;;;;;iBAMhC,CAAA;AACF,eAAO,MAAM,uBAAuB;;;;iBAIlC,CAAA;AAEF,eAAO,MAAM,uBAAuB;;iBAAyC,CAAA;AAC7E,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;mBAUrC,CAAA;AAEF,eAAO,MAAM,gBAAgB;;;;;;;;iBAQ3B,CAAA;AACF,eAAO,MAAM,eAAe;;;;;;;;;;iBAAmD,CAAA;AAI/E,eAAO,MAAM,YAAY;;;;;;;;;;;;;;iBAUvB,CAAA;AACF,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;iBAA4C,CAAA;AAEzE,eAAO,MAAM,iBAAiB;;;;iBAM5B,CAAA;AACF,eAAO,MAAM,mBAAmB;;;;iBAI9B,CAAA;AAIF,eAAO,MAAM,cAAc;;;;;;;;;;;;;iBASzB,CAAA;AACF,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;iBAA8C,CAAA;AAE7E,eAAO,MAAM,mBAAmB;;;iBAG9B,CAAA;AACF,eAAO,MAAM,qBAAqB;;;;iBAIhC,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/schemas/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,eAAO,MAAM,iBAAiB;;;;;;;;;;iBAY5B,CAAA;AAEF,eAAO,MAAM,UAAU;;;;;;iBAMrB,CAAA;AAEF,eAAO,MAAM,eAAe;;;;;;iBAM1B,CAAA;AAEF,eAAO,MAAM,qBAAqB;;;;;;;;;;;;iBAAyD,CAAA;AAE3F;;;GAGG;AACH,eAAO,MAAM,sBAAsB;;;;;iBAejC,CAAA;AACF,eAAO,MAAM,wBAAwB;;;;iBAInC,CAAA;AAEF,uEAAuE;AACvE,eAAO,MAAM,sBAAsB;;;;;iBAQjC,CAAA;AAEF,eAAO,MAAM,kBAAkB;;iBAE7B,CAAA;AAEF,+DAA+D;AAC/D,eAAO,MAAM,sBAAsB;;iBAEjC,CAAA;AACF,eAAO,MAAM,oBAAoB;;iBAAiD,CAAA;AAClF,eAAO,MAAM,cAAc;;;;;;;;iBAA2C,CAAA;AACtE,eAAO,MAAM,mBAAmB;;;;;;;;iBAAqD,CAAA;AAIrF,eAAO,MAAM,UAAU;;;EAA8B,CAAA;AAErD,eAAO,MAAM,YAAY;;;;;;;;;iBAMvB,CAAA;AACF,eAAO,MAAM,gBAAgB;;;;;;;;;;;iBAA+C,CAAA;AAE5E,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB;;;;;;;iBAI5B,CAAA;AACF,eAAO,MAAM,kBAAkB;;;;;iBAG7B,CAAA;AAEF,eAAO,MAAM,iBAAiB;;;;;;iBAG5B,CAAA;AAEF,eAAO,MAAM,oBAAoB;;;iBAM/B,CAAA;AACF,eAAO,MAAM,sBAAsB;;;;iBAIjC,CAAA;AAEF,eAAO,MAAM,QAAQ;;iBAAoC,CAAA;AAIzD,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;iBAW3B,CAAA;AACF,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;iBAAgD,CAAA;AAEjF,eAAO,MAAM,qBAAqB;;;;;;iBAMhC,CAAA;AACF,eAAO,MAAM,uBAAuB;;;;iBAIlC,CAAA;AAEF,eAAO,MAAM,uBAAuB;;iBAAyC,CAAA;AAC7E,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;mBAUrC,CAAA;AAIF,eAAO,MAAM,oBAAoB;;;;;;;iBAO/B,CAAA;AACF,eAAO,MAAM,wBAAwB;;;;;;;;;iBAA0D,CAAA;AAE/F,eAAO,MAAM,iBAAiB;;;;iBAI5B,CAAA;AACF,eAAO,MAAM,2BAA2B;;;iBAGtC,CAAA;AAEF,eAAO,MAAM,wBAAwB;;;;;;;;mBAWnC,CAAA;AAEF,eAAO,MAAM,gBAAgB;;;;;;;;iBAQ3B,CAAA;AACF,eAAO,MAAM,eAAe;;;;;;;;;;iBAAmD,CAAA;AAI/E,eAAO,MAAM,YAAY;;;;;;;;;;;;;;iBAUvB,CAAA;AACF,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;iBAA4C,CAAA;AAEzE,eAAO,MAAM,iBAAiB;;;;iBAM5B,CAAA;AACF,eAAO,MAAM,mBAAmB;;;;iBAI9B,CAAA;AAIF,eAAO,MAAM,cAAc;;;;;;;;;;;;;iBASzB,CAAA;AACF,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;iBAA8C,CAAA;AAE7E,eAAO,MAAM,mBAAmB;;;iBAG9B,CAAA;AACF,eAAO,MAAM,qBAAqB;;;;iBAIhC,CAAA"}
@@ -16,6 +16,9 @@ export const ApplicationSchema = z.object({
16
16
  app: z.string().nullable().describe("Application key; consumer workspace claims are filtered by this"),
17
17
  allowSignup: z.boolean().describe("Whether unknown users may sign themselves up"),
18
18
  permissions: z.array(z.string()).describe("The app's declared product-permission catalog"),
19
+ resources: z
20
+ .array(z.string())
21
+ .describe("Protected resource URIs (e.g. the app's MCP server) — valid `resource` audiences for access tokens"),
19
22
  redirectUris: z.array(z.string()),
20
23
  disabled: z.boolean(),
21
24
  createdAt: z.string().describe("ISO 8601 timestamp"),
@@ -63,6 +66,10 @@ export const UpdateApplicationInput = z.object({
63
66
  name: z.string().min(1).optional(),
64
67
  redirectUris: z.array(z.string().min(1)).min(1).optional(),
65
68
  allowSignup: z.boolean().optional(),
69
+ resources: z
70
+ .array(z.string().url())
71
+ .optional()
72
+ .describe("Replace the app's protected resource URIs — absolute https, no fragment"),
66
73
  });
67
74
  export const ClientSecretSchema = z.object({
68
75
  clientSecret: z.string().describe("Plaintext client secret — shown exactly once, never stored"),
@@ -149,6 +156,37 @@ export const UserApiKeyValidationSchema = z.union([
149
156
  }),
150
157
  z.object({ valid: z.literal(false), reason: z.enum(["not_found", "revoked", "expired"]) }),
151
158
  ]);
159
+ // --- Linked identities (a user's ids on other systems) ---
160
+ export const LinkedIdentitySchema = z.object({
161
+ id: z.string(),
162
+ userId: z.string(),
163
+ provider: z.string().describe("The other system, lowercase — slack, whatsapp, telegram"),
164
+ externalId: z.string().describe("The id exactly as that system spells it"),
165
+ label: z.string().nullable(),
166
+ createdAt: z.string(),
167
+ });
168
+ export const LinkedIdentityListSchema = z.object({ identities: z.array(LinkedIdentitySchema) });
169
+ export const LinkIdentityInput = z.object({
170
+ provider: z.string().min(1).describe("slack, whatsapp, telegram… — normalised to lowercase"),
171
+ externalId: z.string().min(1).describe("The id as that system spells it, e.g. a Slack member id"),
172
+ label: z.string().optional().describe("A human label for the console"),
173
+ });
174
+ export const LinkedIdentityCreatedSchema = z.object({
175
+ id: z.string(),
176
+ created: z.boolean().describe("false when the same pair was already this user's"),
177
+ });
178
+ export const IdentityResolutionSchema = z.union([
179
+ z.object({
180
+ found: z.literal(true),
181
+ userId: z.string(),
182
+ email: z.string(),
183
+ name: z.string().nullable(),
184
+ permissions: z
185
+ .array(z.string())
186
+ .describe("The user's product permissions for the asking app; admins get the whole catalog"),
187
+ }),
188
+ z.object({ found: z.literal(false) }),
189
+ ]);
152
190
  export const AuditEntrySchema = z.object({
153
191
  id: z.number(),
154
192
  tableName: z.string(),
@@ -43,6 +43,7 @@ export declare const operations: {
43
43
  app: z.ZodNullable<z.ZodString>;
44
44
  allowSignup: z.ZodBoolean;
45
45
  permissions: z.ZodArray<z.ZodString>;
46
+ resources: z.ZodArray<z.ZodString>;
46
47
  redirectUris: z.ZodArray<z.ZodString>;
47
48
  disabled: z.ZodBoolean;
48
49
  createdAt: z.ZodString;
@@ -79,6 +80,7 @@ export declare const operations: {
79
80
  app: z.ZodNullable<z.ZodString>;
80
81
  allowSignup: z.ZodBoolean;
81
82
  permissions: z.ZodArray<z.ZodString>;
83
+ resources: z.ZodArray<z.ZodString>;
82
84
  redirectUris: z.ZodArray<z.ZodString>;
83
85
  disabled: z.ZodBoolean;
84
86
  createdAt: z.ZodString;
@@ -95,6 +97,7 @@ export declare const operations: {
95
97
  name: z.ZodOptional<z.ZodString>;
96
98
  redirectUris: z.ZodOptional<z.ZodArray<z.ZodString>>;
97
99
  allowSignup: z.ZodOptional<z.ZodBoolean>;
100
+ resources: z.ZodOptional<z.ZodArray<z.ZodString>>;
98
101
  }, z.core.$strip>;
99
102
  readonly successCode: "200";
100
103
  readonly success: z.ZodObject<{
@@ -103,6 +106,7 @@ export declare const operations: {
103
106
  app: z.ZodNullable<z.ZodString>;
104
107
  allowSignup: z.ZodBoolean;
105
108
  permissions: z.ZodArray<z.ZodString>;
109
+ resources: z.ZodArray<z.ZodString>;
106
110
  redirectUris: z.ZodArray<z.ZodString>;
107
111
  disabled: z.ZodBoolean;
108
112
  createdAt: z.ZodString;
@@ -471,6 +475,73 @@ export declare const operations: {
471
475
  ok: z.ZodLiteral<true>;
472
476
  }, z.core.$strip>;
473
477
  };
478
+ readonly "get /api/v1/users/{userId}/identities": {
479
+ readonly summary: "List a user's linked identities (their ids on other systems)";
480
+ readonly description: "Requires an admin key. Identities are global to the user, not per app — a Slack id identifies a person regardless of who is asking.";
481
+ readonly params: {
482
+ readonly userId: "IdP user id.";
483
+ };
484
+ readonly successCode: "200";
485
+ readonly success: z.ZodObject<{
486
+ identities: z.ZodArray<z.ZodObject<{
487
+ id: z.ZodString;
488
+ userId: z.ZodString;
489
+ provider: z.ZodString;
490
+ externalId: z.ZodString;
491
+ label: z.ZodNullable<z.ZodString>;
492
+ createdAt: z.ZodString;
493
+ }, z.core.$strip>>;
494
+ }, z.core.$strip>;
495
+ };
496
+ readonly "post /api/v1/users/{userId}/identities": {
497
+ readonly summary: "Link an external id to a user";
498
+ readonly description: "Requires an admin key: a link asserts identity with nothing to prove it, so no app or member may do it. 201 on a new link, 200 when the same pair was already this user's, 409 `already_linked` when it belongs to someone else — an identity is never silently re-pointed.";
499
+ readonly params: {
500
+ readonly userId: "IdP user id.";
501
+ };
502
+ readonly input: z.ZodObject<{
503
+ provider: z.ZodString;
504
+ externalId: z.ZodString;
505
+ label: z.ZodOptional<z.ZodString>;
506
+ }, z.core.$strip>;
507
+ readonly successCode: "201";
508
+ readonly success: z.ZodObject<{
509
+ id: z.ZodString;
510
+ created: z.ZodBoolean;
511
+ }, z.core.$strip>;
512
+ };
513
+ readonly "delete /api/v1/users/{userId}/identities/{id}": {
514
+ readonly summary: "Unlink an external id (idempotent)";
515
+ readonly description: "Requires an admin key.";
516
+ readonly params: {
517
+ readonly userId: "IdP user id.";
518
+ readonly id: "Linked identity id.";
519
+ };
520
+ readonly successCode: "200";
521
+ readonly success: z.ZodObject<{
522
+ ok: z.ZodLiteral<true>;
523
+ }, z.core.$strip>;
524
+ };
525
+ readonly "get /api/v1/apps/{app}/identities/{provider}/{externalId}": {
526
+ readonly summary: "Resolve an external id to a user and their permissions in this app";
527
+ readonly description: "The hot path for an app that hears from someone on another system. Always 200 with a `found` discriminator — a miss is data, and the common case in any shared channel. `permissions` are the user's product permissions for THIS app, computed exactly as the claims hook computes them at token mint, so a Slack message and a browser session from the same person carry the same grants. A user with no membership resolves as found with no permissions.";
528
+ readonly permission: "identity:resolve";
529
+ readonly params: {
530
+ readonly provider: "The other system, e.g. slack.";
531
+ readonly externalId: "The id as that system spells it.";
532
+ readonly app: "Application key (oauth_client.metadata.app).";
533
+ };
534
+ readonly successCode: "200";
535
+ readonly success: z.ZodUnion<readonly [z.ZodObject<{
536
+ found: z.ZodLiteral<true>;
537
+ userId: z.ZodString;
538
+ email: z.ZodString;
539
+ name: z.ZodNullable<z.ZodString>;
540
+ permissions: z.ZodArray<z.ZodString>;
541
+ }, z.core.$strip>, z.ZodObject<{
542
+ found: z.ZodLiteral<false>;
543
+ }, z.core.$strip>]>;
544
+ };
474
545
  readonly "get /api/v1/apps/{app}/audit": {
475
546
  readonly summary: "List recent audit entries";
476
547
  readonly permission: "audit:read";
@@ -1 +1 @@
1
- {"version":3,"file":"operations.d.ts","sourceRoot":"","sources":["../../../src/schemas/operations.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAkCvB,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAA;AAEpE,iFAAiF;AACjF,MAAM,MAAM,UAAU,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,CAAA;AAEnF,MAAM,MAAM,YAAY,GAAG;IACzB,OAAO,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,KAAK,CAAC,EAAE,SAAS,UAAU,EAAE,CAAA;IAC7B,wEAAwE;IACxE,MAAM,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACzC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,CAAC,CAAC,OAAO,CAAA;IACjB,WAAW,EAAE,KAAK,GAAG,KAAK,CAAA;IAC1B,OAAO,EAAE,CAAC,CAAC,OAAO,CAAA;CACnB,CAAA;AAKD,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6M0B,CAAA;AAEjD,MAAM,MAAM,UAAU,GAAG,OAAO,UAAU,CAAA;AAC1C,MAAM,MAAM,YAAY,GAAG,MAAM,UAAU,CAAA;AAE3C,mEAAmE;AACnE,MAAM,MAAM,QAAQ,CAAC,CAAC,SAAS,UAAU,IAAI,YAAY,SAAS,MAAM,CAAC,GACrE,CAAC,SAAS,GAAG,CAAC,IAAI,MAAM,CAAC,EAAE,GACzB,CAAC,GACD,KAAK,GACP,KAAK,CAAA;AAET,MAAM,MAAM,YAAY,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,MAAM,IAAI,GAAG,CAAC,IAAI,CAAC,EAAE,SAAS,YAAY,GAC/F,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,GACvB,KAAK,CAAA;AAET,iFAAiF;AACjF,MAAM,MAAM,cAAc,CAAC,CAAC,SAAS,MAAM,IAAI,CAAC,SAAS,GAAG,MAAM,IAAI,MAAM,IAAI,IAAI,MAAM,IAAI,EAAE,GAC5F,IAAI,GAAG,cAAc,CAAC,IAAI,CAAC,GAC3B,KAAK,CAAA;AAET,wBAAgB,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAE7E"}
1
+ {"version":3,"file":"operations.d.ts","sourceRoot":"","sources":["../../../src/schemas/operations.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAsCvB,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAA;AAEpE,iFAAiF;AACjF,MAAM,MAAM,UAAU,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,CAAA;AAEnF,MAAM,MAAM,YAAY,GAAG;IACzB,OAAO,EAAE,MAAM,CAAA;IACf;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,KAAK,CAAC,EAAE,SAAS,UAAU,EAAE,CAAA;IAC7B,wEAAwE;IACxE,MAAM,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IACzC,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,KAAK,CAAC,EAAE,CAAC,CAAC,OAAO,CAAA;IACjB,WAAW,EAAE,KAAK,GAAG,KAAK,CAAA;IAC1B,OAAO,EAAE,CAAC,CAAC,OAAO,CAAA;CACnB,CAAA;AAKD,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+O0B,CAAA;AAEjD,MAAM,MAAM,UAAU,GAAG,OAAO,UAAU,CAAA;AAC1C,MAAM,MAAM,YAAY,GAAG,MAAM,UAAU,CAAA;AAE3C,mEAAmE;AACnE,MAAM,MAAM,QAAQ,CAAC,CAAC,SAAS,UAAU,IAAI,YAAY,SAAS,MAAM,CAAC,GACrE,CAAC,SAAS,GAAG,CAAC,IAAI,MAAM,CAAC,EAAE,GACzB,CAAC,GACD,KAAK,GACP,KAAK,CAAA;AAET,MAAM,MAAM,YAAY,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,MAAM,IAAI,GAAG,CAAC,IAAI,CAAC,EAAE,SAAS,YAAY,GAC/F,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,GACvB,KAAK,CAAA;AAET,iFAAiF;AACjF,MAAM,MAAM,cAAc,CAAC,CAAC,SAAS,MAAM,IAAI,CAAC,SAAS,GAAG,MAAM,IAAI,MAAM,IAAI,IAAI,MAAM,IAAI,EAAE,GAC5F,IAAI,GAAG,cAAc,CAAC,IAAI,CAAC,GAC3B,KAAK,CAAA;AAET,wBAAgB,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAE7E"}
@@ -8,7 +8,7 @@
8
8
  * needs to learn about it.
9
9
  */
10
10
  import { z } from "zod";
11
- import { AdminKeyCreatedSchema, AdminKeyListSchema, ApiKeyCreatedSchema, ApiKeyListSchema, ApplicationCreatedSchema, ApplicationListSchema, ApplicationSchema, AppPermissionsSchema, AuditListSchema, ClientSecretSchema, CreateAdminKeyInput, CreateApiKeyInput, CreateApplicationInput, CreateUserApiKeyInput, CreateWorkspaceInput, InviteMemberInput, InviteMemberResult, MemberListSchema, OkSchema, SetAppPermissionsInput, UpdateApplicationInput, UpdateMemberInput, UserApiKeyCreatedSchema, UserApiKeyListSchema, UserApiKeyValidationSchema, UserListSchema, ValidateUserApiKeyInput, WorkspaceCreatedSchema, WorkspaceListSchema, } from "./index.js";
11
+ import { AdminKeyCreatedSchema, AdminKeyListSchema, ApiKeyCreatedSchema, ApiKeyListSchema, ApplicationCreatedSchema, ApplicationListSchema, ApplicationSchema, AppPermissionsSchema, AuditListSchema, ClientSecretSchema, CreateAdminKeyInput, CreateApiKeyInput, CreateApplicationInput, CreateUserApiKeyInput, CreateWorkspaceInput, InviteMemberInput, InviteMemberResult, MemberListSchema, OkSchema, SetAppPermissionsInput, UpdateApplicationInput, UpdateMemberInput, UserApiKeyCreatedSchema, UserApiKeyListSchema, UserApiKeyValidationSchema, LinkedIdentityListSchema, LinkIdentityInput, LinkedIdentityCreatedSchema, IdentityResolutionSchema, UserListSchema, ValidateUserApiKeyInput, WorkspaceCreatedSchema, WorkspaceListSchema, } from "./index.js";
12
12
  const APP_PARAM = { app: "Application key (oauth_client.metadata.app)." };
13
13
  const CLIENT_PARAM = { clientId: "OAuth client id of the application." };
14
14
  export const operations = {
@@ -200,6 +200,36 @@ export const operations = {
200
200
  successCode: "200",
201
201
  success: OkSchema,
202
202
  },
203
+ "get /api/v1/users/{userId}/identities": {
204
+ summary: "List a user's linked identities (their ids on other systems)",
205
+ description: "Requires an admin key. Identities are global to the user, not per app — a Slack id identifies a person regardless of who is asking.",
206
+ params: { userId: "IdP user id." },
207
+ successCode: "200",
208
+ success: LinkedIdentityListSchema,
209
+ },
210
+ "post /api/v1/users/{userId}/identities": {
211
+ summary: "Link an external id to a user",
212
+ description: "Requires an admin key: a link asserts identity with nothing to prove it, so no app or member may do it. 201 on a new link, 200 when the same pair was already this user's, 409 `already_linked` when it belongs to someone else — an identity is never silently re-pointed.",
213
+ params: { userId: "IdP user id." },
214
+ input: LinkIdentityInput,
215
+ successCode: "201",
216
+ success: LinkedIdentityCreatedSchema,
217
+ },
218
+ "delete /api/v1/users/{userId}/identities/{id}": {
219
+ summary: "Unlink an external id (idempotent)",
220
+ description: "Requires an admin key.",
221
+ params: { userId: "IdP user id.", id: "Linked identity id." },
222
+ successCode: "200",
223
+ success: OkSchema,
224
+ },
225
+ "get /api/v1/apps/{app}/identities/{provider}/{externalId}": {
226
+ summary: "Resolve an external id to a user and their permissions in this app",
227
+ description: "The hot path for an app that hears from someone on another system. Always 200 with a `found` discriminator — a miss is data, and the common case in any shared channel. `permissions` are the user's product permissions for THIS app, computed exactly as the claims hook computes them at token mint, so a Slack message and a browser session from the same person carry the same grants. A user with no membership resolves as found with no permissions.",
228
+ permission: "identity:resolve",
229
+ params: { ...APP_PARAM, provider: "The other system, e.g. slack.", externalId: "The id as that system spells it." },
230
+ successCode: "200",
231
+ success: IdentityResolutionSchema,
232
+ },
203
233
  "get /api/v1/apps/{app}/audit": {
204
234
  summary: "List recent audit entries",
205
235
  permission: "audit:read",
@@ -2326,6 +2326,360 @@
2326
2326
  }
2327
2327
  }
2328
2328
  },
2329
+ "/api/v1/users/{userId}/identities": {
2330
+ "get": {
2331
+ "summary": "List a user's linked identities (their ids on other systems)",
2332
+ "description": "Requires an admin key. Identities are global to the user, not per app — a Slack id identifies a person regardless of who is asking.",
2333
+ "security": [
2334
+ {
2335
+ "bearerAuth": []
2336
+ }
2337
+ ],
2338
+ "parameters": [
2339
+ {
2340
+ "name": "userId",
2341
+ "in": "path",
2342
+ "required": true,
2343
+ "description": "IdP user id.",
2344
+ "schema": {
2345
+ "type": "string"
2346
+ }
2347
+ }
2348
+ ],
2349
+ "responses": {
2350
+ "200": {
2351
+ "description": "OK",
2352
+ "content": {
2353
+ "application/json": {
2354
+ "schema": {
2355
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
2356
+ "type": "object",
2357
+ "properties": {
2358
+ "identities": {
2359
+ "type": "array",
2360
+ "items": {
2361
+ "type": "object",
2362
+ "properties": {
2363
+ "id": {
2364
+ "type": "string"
2365
+ },
2366
+ "userId": {
2367
+ "type": "string"
2368
+ },
2369
+ "provider": {
2370
+ "type": "string",
2371
+ "description": "The other system, lowercase — slack, whatsapp, telegram"
2372
+ },
2373
+ "externalId": {
2374
+ "type": "string",
2375
+ "description": "The id exactly as that system spells it"
2376
+ },
2377
+ "label": {
2378
+ "anyOf": [
2379
+ {
2380
+ "type": "string"
2381
+ },
2382
+ {
2383
+ "type": "null"
2384
+ }
2385
+ ]
2386
+ },
2387
+ "createdAt": {
2388
+ "type": "string"
2389
+ }
2390
+ },
2391
+ "required": [
2392
+ "id",
2393
+ "userId",
2394
+ "provider",
2395
+ "externalId",
2396
+ "label",
2397
+ "createdAt"
2398
+ ],
2399
+ "additionalProperties": false
2400
+ }
2401
+ }
2402
+ },
2403
+ "required": [
2404
+ "identities"
2405
+ ],
2406
+ "additionalProperties": false
2407
+ }
2408
+ }
2409
+ }
2410
+ },
2411
+ "401": {
2412
+ "description": "Missing or invalid bearer token"
2413
+ }
2414
+ }
2415
+ },
2416
+ "post": {
2417
+ "summary": "Link an external id to a user",
2418
+ "description": "Requires an admin key: a link asserts identity with nothing to prove it, so no app or member may do it. 201 on a new link, 200 when the same pair was already this user's, 409 `already_linked` when it belongs to someone else — an identity is never silently re-pointed.",
2419
+ "security": [
2420
+ {
2421
+ "bearerAuth": []
2422
+ }
2423
+ ],
2424
+ "parameters": [
2425
+ {
2426
+ "name": "userId",
2427
+ "in": "path",
2428
+ "required": true,
2429
+ "description": "IdP user id.",
2430
+ "schema": {
2431
+ "type": "string"
2432
+ }
2433
+ }
2434
+ ],
2435
+ "requestBody": {
2436
+ "required": true,
2437
+ "content": {
2438
+ "application/json": {
2439
+ "schema": {
2440
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
2441
+ "type": "object",
2442
+ "properties": {
2443
+ "provider": {
2444
+ "type": "string",
2445
+ "minLength": 1,
2446
+ "description": "slack, whatsapp, telegram… — normalised to lowercase"
2447
+ },
2448
+ "externalId": {
2449
+ "type": "string",
2450
+ "minLength": 1,
2451
+ "description": "The id as that system spells it, e.g. a Slack member id"
2452
+ },
2453
+ "label": {
2454
+ "description": "A human label for the console",
2455
+ "type": "string"
2456
+ }
2457
+ },
2458
+ "required": [
2459
+ "provider",
2460
+ "externalId"
2461
+ ]
2462
+ }
2463
+ }
2464
+ }
2465
+ },
2466
+ "responses": {
2467
+ "201": {
2468
+ "description": "OK",
2469
+ "content": {
2470
+ "application/json": {
2471
+ "schema": {
2472
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
2473
+ "type": "object",
2474
+ "properties": {
2475
+ "id": {
2476
+ "type": "string"
2477
+ },
2478
+ "created": {
2479
+ "type": "boolean",
2480
+ "description": "false when the same pair was already this user's"
2481
+ }
2482
+ },
2483
+ "required": [
2484
+ "id",
2485
+ "created"
2486
+ ],
2487
+ "additionalProperties": false
2488
+ }
2489
+ }
2490
+ }
2491
+ },
2492
+ "401": {
2493
+ "description": "Missing or invalid bearer token"
2494
+ },
2495
+ "405": {
2496
+ "description": "Method not allowed on this resource (see the `Allow` header)"
2497
+ },
2498
+ "409": {
2499
+ "description": "Conflict (already a member, last admin, slug taken, …)"
2500
+ },
2501
+ "422": {
2502
+ "description": "Body failed validation"
2503
+ }
2504
+ }
2505
+ }
2506
+ },
2507
+ "/api/v1/users/{userId}/identities/{id}": {
2508
+ "delete": {
2509
+ "summary": "Unlink an external id (idempotent)",
2510
+ "description": "Requires an admin key.",
2511
+ "security": [
2512
+ {
2513
+ "bearerAuth": []
2514
+ }
2515
+ ],
2516
+ "parameters": [
2517
+ {
2518
+ "name": "userId",
2519
+ "in": "path",
2520
+ "required": true,
2521
+ "description": "IdP user id.",
2522
+ "schema": {
2523
+ "type": "string"
2524
+ }
2525
+ },
2526
+ {
2527
+ "name": "id",
2528
+ "in": "path",
2529
+ "required": true,
2530
+ "description": "Linked identity id.",
2531
+ "schema": {
2532
+ "type": "string"
2533
+ }
2534
+ }
2535
+ ],
2536
+ "responses": {
2537
+ "200": {
2538
+ "description": "OK",
2539
+ "content": {
2540
+ "application/json": {
2541
+ "schema": {
2542
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
2543
+ "type": "object",
2544
+ "properties": {
2545
+ "ok": {
2546
+ "type": "boolean",
2547
+ "const": true
2548
+ }
2549
+ },
2550
+ "required": [
2551
+ "ok"
2552
+ ],
2553
+ "additionalProperties": false
2554
+ }
2555
+ }
2556
+ }
2557
+ },
2558
+ "401": {
2559
+ "description": "Missing or invalid bearer token"
2560
+ },
2561
+ "405": {
2562
+ "description": "Method not allowed on this resource (see the `Allow` header)"
2563
+ },
2564
+ "409": {
2565
+ "description": "Conflict (already a member, last admin, slug taken, …)"
2566
+ }
2567
+ }
2568
+ }
2569
+ },
2570
+ "/api/v1/apps/{app}/identities/{provider}/{externalId}": {
2571
+ "get": {
2572
+ "summary": "Resolve an external id to a user and their permissions in this app",
2573
+ "description": "The hot path for an app that hears from someone on another system. Always 200 with a `found` discriminator — a miss is data, and the common case in any shared channel. `permissions` are the user's product permissions for THIS app, computed exactly as the claims hook computes them at token mint, so a Slack message and a browser session from the same person carry the same grants. A user with no membership resolves as found with no permissions.",
2574
+ "security": [
2575
+ {
2576
+ "bearerAuth": []
2577
+ }
2578
+ ],
2579
+ "parameters": [
2580
+ {
2581
+ "name": "app",
2582
+ "in": "path",
2583
+ "required": true,
2584
+ "description": "Application key (oauth_client.metadata.app).",
2585
+ "schema": {
2586
+ "type": "string"
2587
+ }
2588
+ },
2589
+ {
2590
+ "name": "provider",
2591
+ "in": "path",
2592
+ "required": true,
2593
+ "description": "The other system, e.g. slack.",
2594
+ "schema": {
2595
+ "type": "string"
2596
+ }
2597
+ },
2598
+ {
2599
+ "name": "externalId",
2600
+ "in": "path",
2601
+ "required": true,
2602
+ "description": "The id as that system spells it.",
2603
+ "schema": {
2604
+ "type": "string"
2605
+ }
2606
+ }
2607
+ ],
2608
+ "responses": {
2609
+ "200": {
2610
+ "description": "OK",
2611
+ "content": {
2612
+ "application/json": {
2613
+ "schema": {
2614
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
2615
+ "anyOf": [
2616
+ {
2617
+ "type": "object",
2618
+ "properties": {
2619
+ "found": {
2620
+ "type": "boolean",
2621
+ "const": true
2622
+ },
2623
+ "userId": {
2624
+ "type": "string"
2625
+ },
2626
+ "email": {
2627
+ "type": "string"
2628
+ },
2629
+ "name": {
2630
+ "anyOf": [
2631
+ {
2632
+ "type": "string"
2633
+ },
2634
+ {
2635
+ "type": "null"
2636
+ }
2637
+ ]
2638
+ },
2639
+ "permissions": {
2640
+ "type": "array",
2641
+ "items": {
2642
+ "type": "string"
2643
+ },
2644
+ "description": "The user's product permissions for the asking app; admins get the whole catalog"
2645
+ }
2646
+ },
2647
+ "required": [
2648
+ "found",
2649
+ "userId",
2650
+ "email",
2651
+ "name",
2652
+ "permissions"
2653
+ ],
2654
+ "additionalProperties": false
2655
+ },
2656
+ {
2657
+ "type": "object",
2658
+ "properties": {
2659
+ "found": {
2660
+ "type": "boolean",
2661
+ "const": false
2662
+ }
2663
+ },
2664
+ "required": [
2665
+ "found"
2666
+ ],
2667
+ "additionalProperties": false
2668
+ }
2669
+ ]
2670
+ }
2671
+ }
2672
+ }
2673
+ },
2674
+ "401": {
2675
+ "description": "Missing or invalid bearer token"
2676
+ },
2677
+ "403": {
2678
+ "description": "Key lacks the permission / is bound to another app"
2679
+ }
2680
+ }
2681
+ }
2682
+ },
2329
2683
  "/api/v1/apps/{app}/audit": {
2330
2684
  "get": {
2331
2685
  "summary": "List recent audit entries",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@willyim/idp",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Login for apps that don't own identity \u2014 OIDC client, server sessions, and react-router guards against the willy.im IdP",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",