@baseworks/iam 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.
@@ -0,0 +1,53 @@
1
+ // src/events.ts
2
+ import { z } from "zod";
3
+ import { defineAggregate } from "@baseworks/foldbase";
4
+ var UserEvents = defineAggregate("user", {
5
+ // First sight of an OIDC identity (auth-service POST /sync). id = new user id.
6
+ UserSynced: z.object({
7
+ subject: z.string().min(1),
8
+ issuer: z.string().min(1),
9
+ email: z.string().min(1),
10
+ name: z.string().nullable(),
11
+ picture: z.string().nullable(),
12
+ at: z.number().int()
13
+ }),
14
+ // A later /sync for the same identity refreshes the profile fields.
15
+ UserUpdated: z.object({
16
+ email: z.string().min(1),
17
+ name: z.string().nullable(),
18
+ picture: z.string().nullable(),
19
+ at: z.number().int()
20
+ })
21
+ });
22
+ var TOKEN_TYPES = ["user", "service", "cli", "agent"];
23
+ var TokenType = z.enum(TOKEN_TYPES);
24
+ var ApiTokenEvents = defineAggregate("api_token", {
25
+ TokenIssued: z.object({
26
+ tokenType: TokenType,
27
+ // Opaque context — iam does NOT validate org membership (org owns that).
28
+ organizationId: z.string().nullable(),
29
+ userId: z.string().nullable(),
30
+ name: z.string().min(1),
31
+ tokenHash: z.string().min(1),
32
+ keyPrefix: z.string().min(1),
33
+ scopes: z.string(),
34
+ // JSON string[]
35
+ metadata: z.string(),
36
+ // JSON object
37
+ expiresAt: z.number().int().nullable(),
38
+ at: z.number().int()
39
+ }),
40
+ // Throttled last-used stamp (not written on every request).
41
+ TokenUsed: z.object({
42
+ at: z.number().int()
43
+ }),
44
+ // Revoke removes the read-model row; the event stays in the log.
45
+ TokenRevoked: z.object({})
46
+ });
47
+
48
+ export {
49
+ UserEvents,
50
+ TOKEN_TYPES,
51
+ TokenType,
52
+ ApiTokenEvents
53
+ };
package/dist/cli.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { Command } from 'commander';
2
+ import { ApiClient } from '@baseworks/cli/client';
3
+
4
+ /**
5
+ * Importable `iam` command group — identity + credentials only. Any app's CLI
6
+ * registers it:
7
+ *
8
+ * import { buildIamCommand } from '@baseworks/iam/cli'
9
+ * program.addCommand(buildIamCommand({ http, cliName: 'flect' }))
10
+ *
11
+ * `http` is a `@baseworks/cli` ApiClient whose base points at the app's IAM
12
+ * mount and carries the caller's bearer token. Org/role/member/PDP commands are
13
+ * NOT here — they live in `@baseworks/org/cli` (a user has no org of their own).
14
+ */
15
+ interface IamCliDeps {
16
+ http: ApiClient;
17
+ cliName?: string;
18
+ }
19
+ declare function buildIamCommand(deps: IamCliDeps): Command;
20
+
21
+ export { type IamCliDeps, buildIamCommand };
package/dist/cli.js ADDED
@@ -0,0 +1,68 @@
1
+ // src/cli.ts
2
+ import { Command } from "commander";
3
+ import { kv, table, success, fatal, printOutput } from "@baseworks/cli/display";
4
+ var die = (e) => fatal(e instanceof Error ? e.message : String(e));
5
+ var outputOpt = (c) => c.option("-o, --output <format>", "output format: table | json | yaml", "table");
6
+ function buildIamCommand(deps) {
7
+ const { http } = deps;
8
+ const iam = new Command("iam").description("identity & credentials (IAM)");
9
+ outputOpt(iam.command("whoami")).description("show the signed-in user identity").action(async (opts) => {
10
+ const { user } = await http.get("/me").catch(die);
11
+ if (opts.output && opts.output !== "table") {
12
+ return printOutput(user, opts.output, () => {
13
+ });
14
+ }
15
+ kv([
16
+ ["user", `${user.name ?? user.email} <${user.email}>`],
17
+ ["id", user.id]
18
+ ]);
19
+ });
20
+ const users = iam.command("users").description("user directory");
21
+ users.command("find <email>").description("resolve a user id by exact email (for invites)").action(async (email) => {
22
+ const { user } = await http.get(
23
+ `/users/lookup?email=${encodeURIComponent(email)}`
24
+ ).catch(die);
25
+ kv([
26
+ ["id", user.id],
27
+ ["email", user.email],
28
+ ["name", user.name ?? "\u2014"]
29
+ ]);
30
+ });
31
+ const token = iam.command("token").description("api tokens (service / cli / agent credentials)");
32
+ outputOpt(token.command("list")).description("active tokens you can see").action(async (opts) => {
33
+ const { tokens } = await http.get(
34
+ "/v1/tokens"
35
+ ).catch(die);
36
+ printOutput(
37
+ tokens,
38
+ opts.output,
39
+ () => table(tokens, [
40
+ { key: "name", label: "NAME" },
41
+ { key: "type", label: "TYPE" },
42
+ { key: "keyPrefix", label: "PREFIX" },
43
+ { key: "id", label: "ID" }
44
+ ])
45
+ );
46
+ });
47
+ token.command("issue <name>").description("issue a token; the secret is printed once").option("--type <type>", "user | service | cli | agent", "service").option("--org <id>", "bind to an organization (opaque context)").option("--scope <scope...>", "grant scopes").action(async (name, opts) => {
48
+ const res = await http.post("/v1/tokens", {
49
+ name,
50
+ type: opts.type,
51
+ organizationId: opts.org ?? null,
52
+ scopes: opts.scope ?? []
53
+ }).catch(die);
54
+ success(`token issued: ${res.token.id}`);
55
+ kv([
56
+ ["secret", res.secret],
57
+ ["note", "store it now \u2014 it is not shown again"]
58
+ ]);
59
+ });
60
+ token.command("revoke <id>").description("revoke a token by id").action(async (id) => {
61
+ await http.del(`/v1/tokens/${encodeURIComponent(id)}`).catch(die);
62
+ success(`token revoked: ${id}`);
63
+ });
64
+ return iam;
65
+ }
66
+ export {
67
+ buildIamCommand
68
+ };
@@ -0,0 +1,155 @@
1
+ import { TokenType } from './events.js';
2
+ export { TOKEN_TYPES } from './events.js';
3
+ import '@baseworks/foldbase';
4
+ import 'zod';
5
+
6
+ interface UserRow {
7
+ id: string;
8
+ subject: string;
9
+ issuer: string;
10
+ email: string;
11
+ name: string | null;
12
+ picture: string | null;
13
+ created_at: number;
14
+ updated_at: number;
15
+ }
16
+ interface ApiTokenRow {
17
+ id: string;
18
+ token_type: TokenType;
19
+ organization_id: string | null;
20
+ user_id: string | null;
21
+ name: string;
22
+ token_hash: string;
23
+ key_prefix: string;
24
+ scopes: string;
25
+ metadata: string;
26
+ expires_at: number | null;
27
+ last_used_at: number | null;
28
+ created_at: number;
29
+ updated_at: number;
30
+ }
31
+ interface User {
32
+ id: string;
33
+ subject: string;
34
+ issuer: string;
35
+ email: string;
36
+ name: string | null;
37
+ picture: string | null;
38
+ createdAt: number;
39
+ updatedAt: number;
40
+ }
41
+ /** Public-safe identity projection (returned by /me and /users/lookup). */
42
+ interface PublicUser {
43
+ id: string;
44
+ email: string;
45
+ name: string | null;
46
+ }
47
+ /** An OIDC identity presented to POST /sync (before a user id exists). */
48
+ interface OidcIdentity {
49
+ subject: string;
50
+ issuer: string;
51
+ email: string;
52
+ name?: string | null;
53
+ picture?: string | null;
54
+ }
55
+ /** A token as listed — never carries the secret or its hash. */
56
+ interface ApiToken {
57
+ id: string;
58
+ type: TokenType;
59
+ organizationId: string | null;
60
+ userId: string | null;
61
+ name: string;
62
+ keyPrefix: string;
63
+ scopes: string[];
64
+ metadata: Record<string, unknown>;
65
+ expiresAt: number | null;
66
+ lastUsedAt: number | null;
67
+ createdAt: number;
68
+ updatedAt: number;
69
+ }
70
+ /** The one-time issue response — `token` is the secret, shown only here. */
71
+ interface IssuedToken {
72
+ token: ApiToken;
73
+ /** The full secret; surfaced exactly once at creation. */
74
+ secret: string;
75
+ }
76
+ /** The identity a verified token resolves to. */
77
+ interface TokenPrincipal {
78
+ tokenId: string;
79
+ type: TokenType;
80
+ organizationId: string | null;
81
+ userId: string | null;
82
+ scopes: string[];
83
+ }
84
+
85
+ /** Forwarded end-user identity (X-Auth-*) for a trusted service caller. */
86
+ interface AuthContext {
87
+ uid?: string;
88
+ role?: string;
89
+ email?: string;
90
+ }
91
+ interface IamClientOptions {
92
+ /** Base URL of iam-service (resolved by the SDK, e.g. IAM_SERVICE_URL). */
93
+ baseUrl: string;
94
+ /** Bearer token — the caller's service or user token. */
95
+ token?: string;
96
+ /** Forwarded end-user identity. */
97
+ auth?: AuthContext;
98
+ /** Injectable fetch (tests / non-global environments). */
99
+ fetch?: typeof fetch;
100
+ }
101
+ /** Thrown on any non-2xx response from iam-service. */
102
+ declare class IamError extends Error {
103
+ readonly status: number;
104
+ readonly code: string;
105
+ constructor(status: number, code: string, message?: string);
106
+ }
107
+ /** Minimal ApiClient shape — structurally compatible with @baseworks/cli. */
108
+ interface HttpClient {
109
+ api<T>(method: string, path: string, body?: unknown, headers?: Record<string, string>): Promise<T>;
110
+ get<T>(path: string, headers?: Record<string, string>): Promise<T>;
111
+ post<T>(path: string, body: unknown, headers?: Record<string, string>): Promise<T>;
112
+ del<T>(path: string, headers?: Record<string, string>): Promise<T>;
113
+ }
114
+ /**
115
+ * Typed HTTP client for iam-service. Zod-free at the boundary (the service
116
+ * validates); a thin, ergonomic wrapper over the REST surface. `.http` exposes
117
+ * a generic ApiClient the CLI plugin consumes.
118
+ */
119
+ declare class IamClient {
120
+ private readonly opts;
121
+ private readonly baseUrl;
122
+ private readonly fetchImpl;
123
+ constructor(opts: IamClientOptions);
124
+ /** Return a new client that forwards a different end-user identity. */
125
+ withAuth(auth: AuthContext): IamClient;
126
+ private headers;
127
+ api<T>(method: string, path: string, body?: unknown, extra?: Record<string, string>): Promise<T>;
128
+ /** Generic ApiClient (get/post/del) — used by the CLI plugin. */
129
+ get http(): HttpClient;
130
+ /** The signed-in user's own identity (no orgs — those live in @baseworks/org). */
131
+ me(): Promise<PublicUser>;
132
+ /** Upsert an OIDC identity → the platform user. Called by auth-service (no auth). */
133
+ sync(identity: OidcIdentity): Promise<{
134
+ id: string;
135
+ }>;
136
+ /** Resolve a user id by exact email (invite flows). */
137
+ lookupByEmail(email: string): Promise<PublicUser>;
138
+ issueToken(input: {
139
+ name: string;
140
+ type?: TokenType;
141
+ organizationId?: string | null;
142
+ userId?: string | null;
143
+ scopes?: string[];
144
+ expiresAt?: number | null;
145
+ metadata?: Record<string, unknown>;
146
+ }): Promise<IssuedToken>;
147
+ listTokens(): Promise<ApiToken[]>;
148
+ revokeToken(id: string): Promise<void>;
149
+ /** Verify a raw token secret → its principal, or throw IamError(401). */
150
+ verifyToken(secret: string): Promise<TokenPrincipal>;
151
+ }
152
+ /** Factory used by the SDK capability registry. */
153
+ declare function createIamClient(opts: IamClientOptions): IamClient;
154
+
155
+ export { type ApiToken, type ApiTokenRow, type AuthContext, type HttpClient, IamClient, type IamClientOptions, IamError, type IssuedToken, type OidcIdentity, type PublicUser, type TokenPrincipal, TokenType, type User, type UserRow, createIamClient };
package/dist/client.js ADDED
@@ -0,0 +1,105 @@
1
+ import {
2
+ TOKEN_TYPES
3
+ } from "./chunk-26XPWBFV.js";
4
+
5
+ // src/client.ts
6
+ var IamError = class extends Error {
7
+ constructor(status, code, message) {
8
+ super(message ?? code);
9
+ this.status = status;
10
+ this.code = code;
11
+ this.name = "IamError";
12
+ }
13
+ status;
14
+ code;
15
+ };
16
+ var IamClient = class _IamClient {
17
+ constructor(opts) {
18
+ this.opts = opts;
19
+ this.baseUrl = opts.baseUrl.replace(/\/$/, "");
20
+ this.fetchImpl = opts.fetch ?? globalThis.fetch;
21
+ }
22
+ opts;
23
+ baseUrl;
24
+ fetchImpl;
25
+ /** Return a new client that forwards a different end-user identity. */
26
+ withAuth(auth) {
27
+ return new _IamClient({ ...this.opts, auth });
28
+ }
29
+ headers(json, extra = {}) {
30
+ const h = {};
31
+ if (json) h["Content-Type"] = "application/json";
32
+ if (this.opts.token) h["Authorization"] = `Bearer ${this.opts.token}`;
33
+ if (this.opts.auth?.uid) h["X-Auth-UID"] = this.opts.auth.uid;
34
+ if (this.opts.auth?.role) h["X-Auth-Role"] = this.opts.auth.role;
35
+ if (this.opts.auth?.email) h["X-Auth-Email"] = this.opts.auth.email;
36
+ return { ...h, ...extra };
37
+ }
38
+ async api(method, path, body, extra = {}) {
39
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
40
+ method,
41
+ headers: this.headers(body !== void 0, extra),
42
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
43
+ });
44
+ if (res.status === 204) return null;
45
+ const text = await res.text();
46
+ const data = text ? JSON.parse(text) : void 0;
47
+ if (!res.ok) {
48
+ const e = data ?? {};
49
+ const code = typeof e.error === "string" ? e.error : e.error?.code ?? "error";
50
+ const msg = typeof e.error === "object" ? e.error?.message : e.message;
51
+ throw new IamError(res.status, code, msg);
52
+ }
53
+ return data;
54
+ }
55
+ /** Generic ApiClient (get/post/del) — used by the CLI plugin. */
56
+ get http() {
57
+ return {
58
+ api: this.api.bind(this),
59
+ get: (p, h) => this.api("GET", p, void 0, h),
60
+ post: (p, b, h) => this.api("POST", p, b, h),
61
+ del: (p, h) => this.api("DELETE", p, void 0, h)
62
+ };
63
+ }
64
+ // ── identity ────────────────────────────────────────────────────────────────
65
+ /** The signed-in user's own identity (no orgs — those live in @baseworks/org). */
66
+ async me() {
67
+ const { user } = await this.api("GET", "/me");
68
+ return user;
69
+ }
70
+ /** Upsert an OIDC identity → the platform user. Called by auth-service (no auth). */
71
+ async sync(identity) {
72
+ const { user } = await this.api("POST", "/sync", identity);
73
+ return user;
74
+ }
75
+ /** Resolve a user id by exact email (invite flows). */
76
+ async lookupByEmail(email) {
77
+ const { user } = await this.api("GET", `/users/lookup?email=${encodeURIComponent(email)}`);
78
+ return user;
79
+ }
80
+ // ── credentials (api tokens) ─────────────────────────────────────────────────
81
+ async issueToken(input) {
82
+ return this.api("POST", "/v1/tokens", input);
83
+ }
84
+ async listTokens() {
85
+ const { tokens } = await this.api("GET", "/v1/tokens");
86
+ return tokens;
87
+ }
88
+ async revokeToken(id) {
89
+ await this.api("DELETE", `/v1/tokens/${encodeURIComponent(id)}`);
90
+ }
91
+ /** Verify a raw token secret → its principal, or throw IamError(401). */
92
+ async verifyToken(secret) {
93
+ const { principal } = await this.api("POST", "/v1/tokens/verify", { token: secret });
94
+ return principal;
95
+ }
96
+ };
97
+ function createIamClient(opts) {
98
+ return new IamClient(opts);
99
+ }
100
+ export {
101
+ IamClient,
102
+ IamError,
103
+ TOKEN_TYPES,
104
+ createIamClient
105
+ };
@@ -0,0 +1,111 @@
1
+ import * as _baseworks_foldbase from '@baseworks/foldbase';
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * Event catalogs — the single source of truth for iam-service payloads.
6
+ * Shared by the service (typed emit) and tests. Two aggregates:
7
+ *
8
+ * user — a platform identity, keyed on (issuer, subject); id = user id
9
+ * api_token — a service/cli/agent credential; id = token id
10
+ *
11
+ * IAM is identity + credentials ONLY. Roles, memberships, orgs and the PDP live
12
+ * in @baseworks/org — a user has no org of their own, only memberships, so every
13
+ * role question is an org question (see iam-service/AGENTS.md).
14
+ *
15
+ * All timestamps are epoch millis carried in the payload (`at`) so folds stay
16
+ * deterministic. `scopes`/`metadata` are JSON strings the service serializes
17
+ * before emit (mirrors org-service's `metadata` convention).
18
+ */
19
+ declare const UserEvents: _baseworks_foldbase.EventCatalog<{
20
+ UserSynced: z.ZodObject<{
21
+ subject: z.ZodString;
22
+ issuer: z.ZodString;
23
+ email: z.ZodString;
24
+ name: z.ZodNullable<z.ZodString>;
25
+ picture: z.ZodNullable<z.ZodString>;
26
+ at: z.ZodNumber;
27
+ }, "strip", z.ZodTypeAny, {
28
+ email: string;
29
+ at: number;
30
+ subject: string;
31
+ issuer: string;
32
+ name: string | null;
33
+ picture: string | null;
34
+ }, {
35
+ email: string;
36
+ at: number;
37
+ subject: string;
38
+ issuer: string;
39
+ name: string | null;
40
+ picture: string | null;
41
+ }>;
42
+ UserUpdated: z.ZodObject<{
43
+ email: z.ZodString;
44
+ name: z.ZodNullable<z.ZodString>;
45
+ picture: z.ZodNullable<z.ZodString>;
46
+ at: z.ZodNumber;
47
+ }, "strip", z.ZodTypeAny, {
48
+ email: string;
49
+ at: number;
50
+ name: string | null;
51
+ picture: string | null;
52
+ }, {
53
+ email: string;
54
+ at: number;
55
+ name: string | null;
56
+ picture: string | null;
57
+ }>;
58
+ }> & {
59
+ newId(): string;
60
+ };
61
+ declare const TOKEN_TYPES: readonly ["user", "service", "cli", "agent"];
62
+ declare const TokenType: z.ZodEnum<["user", "service", "cli", "agent"]>;
63
+ type TokenType = z.infer<typeof TokenType>;
64
+ declare const ApiTokenEvents: _baseworks_foldbase.EventCatalog<{
65
+ TokenIssued: z.ZodObject<{
66
+ tokenType: z.ZodEnum<["user", "service", "cli", "agent"]>;
67
+ organizationId: z.ZodNullable<z.ZodString>;
68
+ userId: z.ZodNullable<z.ZodString>;
69
+ name: z.ZodString;
70
+ tokenHash: z.ZodString;
71
+ keyPrefix: z.ZodString;
72
+ scopes: z.ZodString;
73
+ metadata: z.ZodString;
74
+ expiresAt: z.ZodNullable<z.ZodNumber>;
75
+ at: z.ZodNumber;
76
+ }, "strip", z.ZodTypeAny, {
77
+ at: number;
78
+ name: string;
79
+ tokenType: "user" | "service" | "cli" | "agent";
80
+ organizationId: string | null;
81
+ userId: string | null;
82
+ tokenHash: string;
83
+ keyPrefix: string;
84
+ scopes: string;
85
+ metadata: string;
86
+ expiresAt: number | null;
87
+ }, {
88
+ at: number;
89
+ name: string;
90
+ tokenType: "user" | "service" | "cli" | "agent";
91
+ organizationId: string | null;
92
+ userId: string | null;
93
+ tokenHash: string;
94
+ keyPrefix: string;
95
+ scopes: string;
96
+ metadata: string;
97
+ expiresAt: number | null;
98
+ }>;
99
+ TokenUsed: z.ZodObject<{
100
+ at: z.ZodNumber;
101
+ }, "strip", z.ZodTypeAny, {
102
+ at: number;
103
+ }, {
104
+ at: number;
105
+ }>;
106
+ TokenRevoked: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
107
+ }> & {
108
+ newId(): string;
109
+ };
110
+
111
+ export { ApiTokenEvents, TOKEN_TYPES, TokenType, UserEvents };
package/dist/events.js ADDED
@@ -0,0 +1,12 @@
1
+ import {
2
+ ApiTokenEvents,
3
+ TOKEN_TYPES,
4
+ TokenType,
5
+ UserEvents
6
+ } from "./chunk-26XPWBFV.js";
7
+ export {
8
+ ApiTokenEvents,
9
+ TOKEN_TYPES,
10
+ TokenType,
11
+ UserEvents
12
+ };
@@ -0,0 +1,19 @@
1
+ import * as _baseworks_foldbase from '@baseworks/foldbase';
2
+ import { PolicyDef } from '@baseworks/foldbase';
3
+
4
+ /**
5
+ * Read-model definitions the service registers on boot (idempotent, like
6
+ * migrations). Columns are inferred from the event field types. `updated_at` is
7
+ * part of foldbase's read-model spine (server-maintained) so it is never
8
+ * declared; the stream id is the `id` column.
9
+ *
10
+ * The service is the authorization layer (route handlers enforce end-user
11
+ * scoping), so policies are permissive for the service's own `admin` identity.
12
+ */
13
+ declare const readUsers: _baseworks_foldbase.ProjectionSpec;
14
+ declare const readApiTokens: _baseworks_foldbase.ProjectionSpec;
15
+ declare const PROJECTIONS: readonly [_baseworks_foldbase.ProjectionSpec, _baseworks_foldbase.ProjectionSpec];
16
+ /** Permissive policies for the service's own `admin` identity. */
17
+ declare const POLICIES: PolicyDef[];
18
+
19
+ export { POLICIES, PROJECTIONS, readApiTokens, readUsers };
@@ -0,0 +1,51 @@
1
+ import {
2
+ ApiTokenEvents,
3
+ UserEvents
4
+ } from "./chunk-26XPWBFV.js";
5
+
6
+ // src/projections.ts
7
+ import { defineProjection } from "@baseworks/foldbase";
8
+ var readUsers = defineProjection("users", UserEvents, (on) => ({
9
+ UserSynced: on.UserSynced.upsert((e) => ({
10
+ subject: e.subject,
11
+ issuer: e.issuer,
12
+ email: e.email,
13
+ name: e.name,
14
+ picture: e.picture,
15
+ created_at: e.at
16
+ })),
17
+ UserUpdated: on.UserUpdated.upsert((e) => ({
18
+ email: e.email,
19
+ name: e.name,
20
+ picture: e.picture
21
+ }))
22
+ }));
23
+ var readApiTokens = defineProjection("api_tokens", ApiTokenEvents, (on) => ({
24
+ TokenIssued: on.TokenIssued.upsert((e) => ({
25
+ token_type: e.tokenType,
26
+ organization_id: e.organizationId,
27
+ user_id: e.userId,
28
+ name: e.name,
29
+ token_hash: e.tokenHash,
30
+ key_prefix: e.keyPrefix,
31
+ scopes: e.scopes,
32
+ metadata: e.metadata,
33
+ expires_at: e.expiresAt,
34
+ created_at: e.at
35
+ })),
36
+ TokenUsed: on.TokenUsed.upsert((e) => ({
37
+ last_used_at: e.at
38
+ })),
39
+ TokenRevoked: on.TokenRevoked.delete()
40
+ }));
41
+ var PROJECTIONS = [readUsers, readApiTokens];
42
+ var POLICIES = [
43
+ { name: "users", role: "admin" },
44
+ { name: "api_tokens", role: "admin" }
45
+ ];
46
+ export {
47
+ POLICIES,
48
+ PROJECTIONS,
49
+ readApiTokens,
50
+ readUsers
51
+ };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@baseworks/iam",
3
+ "version": "0.2.0",
4
+ "description": "IAM capability: identity (users) + credentials (api tokens). Typed HTTP client, foldbase event catalog, projections, and CLI plugin for iam-service. Contract-first, versioned with the service. Roles/PDP live in @baseworks/org.",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "exports": {
10
+ ".": "./dist/client.js",
11
+ "./events": "./dist/events.js",
12
+ "./projections": "./dist/projections.js",
13
+ "./cli": "./dist/cli.js"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src"
18
+ ],
19
+ "dependencies": {
20
+ "@baseworks/cli": "^0.2.0",
21
+ "commander": "^15.0.0",
22
+ "zod": "^3.25.76",
23
+ "@baseworks/foldbase": "0.1.1"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.20.0",
27
+ "msw": "^2.14.6",
28
+ "tsup": "^8.5.1",
29
+ "typescript": "^5.9.3",
30
+ "vitest": "^4.1.9",
31
+ "@baseworks/tsconfig": "0.1.0"
32
+ },
33
+ "scripts": {
34
+ "build": "tsup",
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest"
38
+ }
39
+ }
@@ -0,0 +1,69 @@
1
+ /// <reference types="vitest/globals" />
2
+ import { beforeAll, afterEach, afterAll, describe, it, expect } from 'vitest'
3
+ import { run, mocks, server } from './helpers.js'
4
+
5
+ beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
6
+ afterEach(() => server.resetHandlers())
7
+ afterAll(() => server.close())
8
+
9
+ const USER = { id: '019f05bd-ebf7-7db6-956d-f323dfadba50', email: 'ali@acme.test', name: 'Ali' }
10
+
11
+ // ── whoami ────────────────────────────────────────────────────────────────────
12
+
13
+ describe('flect iam whoami', () => {
14
+ it('shows the signed-in identity', async () => {
15
+ mocks.mockGet('/me', { user: USER })
16
+ const r = await run(['iam', 'whoami'])
17
+ expect(r.exitCode).toBe(0)
18
+ expect(r.stdout).toContain('ali@acme.test')
19
+ expect(r.stdout).toContain(USER.id)
20
+ })
21
+
22
+ it('-o json returns the raw user', async () => {
23
+ mocks.mockGet('/me', { user: USER })
24
+ const r = await run(['iam', 'whoami', '-o', 'json'])
25
+ expect(r.exitCode).toBe(0)
26
+ expect(JSON.parse(r.stdout)).toMatchObject({ email: 'ali@acme.test' })
27
+ })
28
+ })
29
+
30
+ // ── users find ────────────────────────────────────────────────────────────────
31
+
32
+ describe('flect iam users find', () => {
33
+ it('resolves a user id by email', async () => {
34
+ mocks.mockGet('/users/lookup', { user: USER })
35
+ const r = await run(['iam', 'users', 'find', 'ali@acme.test'])
36
+ expect(r.exitCode).toBe(0)
37
+ expect(r.stdout).toContain(USER.id)
38
+ expect(r.stdout).toContain('ali@acme.test')
39
+ })
40
+ })
41
+
42
+ // ── token ─────────────────────────────────────────────────────────────────────
43
+
44
+ describe('flect iam token', () => {
45
+ it('list renders NAME / TYPE / PREFIX columns', async () => {
46
+ mocks.mockGet('/v1/tokens', {
47
+ tokens: [{ id: 't1', name: 'ci', type: 'service', keyPrefix: 'bwt_abc12345', expiresAt: null }],
48
+ })
49
+ const r = await run(['iam', 'token', 'list'])
50
+ expect(r.exitCode).toBe(0)
51
+ expect(r.stdout).toContain('ci')
52
+ expect(r.stdout).toContain('service')
53
+ expect(r.stdout).toContain('bwt_abc12345')
54
+ })
55
+
56
+ it('issue prints the one-time secret', async () => {
57
+ mocks.mockPost('/v1/tokens', { token: { id: 't1', keyPrefix: 'bwt_secret1234' }, secret: 'bwt_secret1234_rest' })
58
+ const r = await run(['iam', 'token', 'issue', 'ci', '--type', 'service'])
59
+ expect(r.exitCode).toBe(0)
60
+ expect(r.stdout).toContain('bwt_secret1234_rest')
61
+ })
62
+
63
+ it('revoke calls DELETE and reports success', async () => {
64
+ mocks.mockDelete('/v1/tokens/t1')
65
+ const r = await run(['iam', 'token', 'revoke', 't1'])
66
+ expect(r.exitCode).toBe(0)
67
+ expect(r.stdout).toContain('t1')
68
+ })
69
+ })
@@ -0,0 +1,24 @@
1
+ import { Command } from 'commander'
2
+ import { buildIamCommand } from '../cli.js'
3
+ import { createApiClient } from '@baseworks/cli/client'
4
+ import { runCommand, createMocks, server } from '@baseworks/cli/testing'
5
+
6
+ export { runCommand, server }
7
+
8
+ export const TEST_BASE = 'http://iam.test'
9
+
10
+ export const mocks = createMocks(TEST_BASE)
11
+
12
+ export function makeProgram(token = 'test-token') {
13
+ const http = createApiClient(
14
+ () => token,
15
+ () => TEST_BASE,
16
+ )
17
+ const program = new Command('flect').exitOverride().enablePositionalOptions()
18
+ program.addCommand(buildIamCommand({ http, cliName: 'flect' }))
19
+ return program
20
+ }
21
+
22
+ export function run(args: string[]) {
23
+ return runCommand(makeProgram(), args)
24
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { Command } from 'commander'
2
+ import { kv, table, success, fatal, printOutput } from '@baseworks/cli/display'
3
+ import type { ApiClient } from '@baseworks/cli/client'
4
+
5
+ /**
6
+ * Importable `iam` command group — identity + credentials only. Any app's CLI
7
+ * registers it:
8
+ *
9
+ * import { buildIamCommand } from '@baseworks/iam/cli'
10
+ * program.addCommand(buildIamCommand({ http, cliName: 'flect' }))
11
+ *
12
+ * `http` is a `@baseworks/cli` ApiClient whose base points at the app's IAM
13
+ * mount and carries the caller's bearer token. Org/role/member/PDP commands are
14
+ * NOT here — they live in `@baseworks/org/cli` (a user has no org of their own).
15
+ */
16
+ export interface IamCliDeps {
17
+ http: ApiClient
18
+ cliName?: string
19
+ }
20
+
21
+ const die = (e: unknown): never => fatal(e instanceof Error ? e.message : String(e))
22
+ const outputOpt = (c: Command) => c.option('-o, --output <format>', 'output format: table | json | yaml', 'table')
23
+
24
+ export function buildIamCommand(deps: IamCliDeps): Command {
25
+ const { http } = deps
26
+ const iam = new Command('iam').description('identity & credentials (IAM)')
27
+
28
+ // ── whoami ──────────────────────────────────────────────────────────────────
29
+ outputOpt(iam.command('whoami'))
30
+ .description('show the signed-in user identity')
31
+ .action(async (opts: { output?: string }) => {
32
+ const { user } = await http
33
+ .get<{ user: { id: string; email: string; name: string | null } }>('/me')
34
+ .catch(die)
35
+ if (opts.output && opts.output !== 'table') {
36
+ return printOutput(user as unknown as Record<string, unknown>, opts.output, () => {})
37
+ }
38
+ kv([
39
+ ['user', `${user.name ?? user.email} <${user.email}>`],
40
+ ['id', user.id],
41
+ ])
42
+ })
43
+
44
+ // ── users ─────────────────────────────────────────────────────────────────--
45
+ const users = iam.command('users').description('user directory')
46
+
47
+ users
48
+ .command('find <email>')
49
+ .description('resolve a user id by exact email (for invites)')
50
+ .action(async (email: string) => {
51
+ const { user } = await http
52
+ .get<{ user: { id: string; email: string; name: string | null } }>(
53
+ `/users/lookup?email=${encodeURIComponent(email)}`,
54
+ )
55
+ .catch(die)
56
+ kv([
57
+ ['id', user.id],
58
+ ['email', user.email],
59
+ ['name', user.name ?? '—'],
60
+ ])
61
+ })
62
+
63
+ // ── token (credentials) ───────────────────────────────────────────────────--
64
+ const token = iam.command('token').description('api tokens (service / cli / agent credentials)')
65
+
66
+ outputOpt(token.command('list'))
67
+ .description('active tokens you can see')
68
+ .action(async (opts: { output?: string }) => {
69
+ const { tokens } = await http
70
+ .get<{ tokens: Array<{ id: string; name: string; type: string; keyPrefix: string; expiresAt: number | null }> }>(
71
+ '/v1/tokens',
72
+ )
73
+ .catch(die)
74
+ printOutput(tokens as unknown as Record<string, unknown>[], opts.output, () =>
75
+ table(tokens as unknown as Record<string, unknown>[], [
76
+ { key: 'name', label: 'NAME' },
77
+ { key: 'type', label: 'TYPE' },
78
+ { key: 'keyPrefix', label: 'PREFIX' },
79
+ { key: 'id', label: 'ID' },
80
+ ]),
81
+ )
82
+ })
83
+
84
+ token
85
+ .command('issue <name>')
86
+ .description('issue a token; the secret is printed once')
87
+ .option('--type <type>', 'user | service | cli | agent', 'service')
88
+ .option('--org <id>', 'bind to an organization (opaque context)')
89
+ .option('--scope <scope...>', 'grant scopes')
90
+ .action(async (name: string, opts: { type?: string; org?: string; scope?: string[] }) => {
91
+ const res = await http
92
+ .post<{ token: { id: string; keyPrefix: string }; secret: string }>('/v1/tokens', {
93
+ name,
94
+ type: opts.type,
95
+ organizationId: opts.org ?? null,
96
+ scopes: opts.scope ?? [],
97
+ })
98
+ .catch(die)
99
+ success(`token issued: ${res.token.id}`)
100
+ kv([
101
+ ['secret', res.secret],
102
+ ['note', 'store it now — it is not shown again'],
103
+ ])
104
+ })
105
+
106
+ token
107
+ .command('revoke <id>')
108
+ .description('revoke a token by id')
109
+ .action(async (id: string) => {
110
+ await http.del(`/v1/tokens/${encodeURIComponent(id)}`).catch(die)
111
+ success(`token revoked: ${id}`)
112
+ })
113
+
114
+ return iam
115
+ }
package/src/client.ts ADDED
@@ -0,0 +1,166 @@
1
+ import type { PublicUser, OidcIdentity, ApiToken, IssuedToken, TokenPrincipal, TokenType } from './types.js'
2
+
3
+ // Value re-export (a runtime const, not just a type) so consumers can build
4
+ // validators from it, e.g. `z.enum(TOKEN_TYPES)`.
5
+ export { TOKEN_TYPES } from './types.js'
6
+
7
+ export type {
8
+ User,
9
+ PublicUser,
10
+ OidcIdentity,
11
+ ApiToken,
12
+ IssuedToken,
13
+ TokenPrincipal,
14
+ TokenType,
15
+ UserRow,
16
+ ApiTokenRow,
17
+ } from './types.js'
18
+
19
+ /** Forwarded end-user identity (X-Auth-*) for a trusted service caller. */
20
+ export interface AuthContext {
21
+ uid?: string
22
+ role?: string
23
+ email?: string
24
+ }
25
+
26
+ export interface IamClientOptions {
27
+ /** Base URL of iam-service (resolved by the SDK, e.g. IAM_SERVICE_URL). */
28
+ baseUrl: string
29
+ /** Bearer token — the caller's service or user token. */
30
+ token?: string
31
+ /** Forwarded end-user identity. */
32
+ auth?: AuthContext
33
+ /** Injectable fetch (tests / non-global environments). */
34
+ fetch?: typeof fetch
35
+ }
36
+
37
+ /** Thrown on any non-2xx response from iam-service. */
38
+ export class IamError extends Error {
39
+ constructor(
40
+ readonly status: number,
41
+ readonly code: string,
42
+ message?: string,
43
+ ) {
44
+ super(message ?? code)
45
+ this.name = 'IamError'
46
+ }
47
+ }
48
+
49
+ /** Minimal ApiClient shape — structurally compatible with @baseworks/cli. */
50
+ export interface HttpClient {
51
+ api<T>(method: string, path: string, body?: unknown, headers?: Record<string, string>): Promise<T>
52
+ get<T>(path: string, headers?: Record<string, string>): Promise<T>
53
+ post<T>(path: string, body: unknown, headers?: Record<string, string>): Promise<T>
54
+ del<T>(path: string, headers?: Record<string, string>): Promise<T>
55
+ }
56
+
57
+ /**
58
+ * Typed HTTP client for iam-service. Zod-free at the boundary (the service
59
+ * validates); a thin, ergonomic wrapper over the REST surface. `.http` exposes
60
+ * a generic ApiClient the CLI plugin consumes.
61
+ */
62
+ export class IamClient {
63
+ private readonly baseUrl: string
64
+ private readonly fetchImpl: typeof fetch
65
+ constructor(private readonly opts: IamClientOptions) {
66
+ this.baseUrl = opts.baseUrl.replace(/\/$/, '')
67
+ this.fetchImpl = opts.fetch ?? globalThis.fetch
68
+ }
69
+
70
+ /** Return a new client that forwards a different end-user identity. */
71
+ withAuth(auth: AuthContext): IamClient {
72
+ return new IamClient({ ...this.opts, auth })
73
+ }
74
+
75
+ private headers(json: boolean, extra: Record<string, string> = {}): Record<string, string> {
76
+ const h: Record<string, string> = {}
77
+ if (json) h['Content-Type'] = 'application/json'
78
+ if (this.opts.token) h['Authorization'] = `Bearer ${this.opts.token}`
79
+ if (this.opts.auth?.uid) h['X-Auth-UID'] = this.opts.auth.uid
80
+ if (this.opts.auth?.role) h['X-Auth-Role'] = this.opts.auth.role
81
+ if (this.opts.auth?.email) h['X-Auth-Email'] = this.opts.auth.email
82
+ return { ...h, ...extra }
83
+ }
84
+
85
+ async api<T>(method: string, path: string, body?: unknown, extra: Record<string, string> = {}): Promise<T> {
86
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
87
+ method,
88
+ headers: this.headers(body !== undefined, extra),
89
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
90
+ })
91
+ if (res.status === 204) return null as T
92
+ const text = await res.text()
93
+ const data = text ? (JSON.parse(text) as unknown) : undefined
94
+ if (!res.ok) {
95
+ const e = (data ?? {}) as { error?: string | { code?: string; message?: string }; message?: string }
96
+ const code = typeof e.error === 'string' ? e.error : e.error?.code ?? 'error'
97
+ const msg = typeof e.error === 'object' ? e.error?.message : e.message
98
+ throw new IamError(res.status, code, msg)
99
+ }
100
+ return data as T
101
+ }
102
+
103
+ /** Generic ApiClient (get/post/del) — used by the CLI plugin. */
104
+ get http(): HttpClient {
105
+ return {
106
+ api: this.api.bind(this),
107
+ get: <T>(p: string, h?: Record<string, string>) => this.api<T>('GET', p, undefined, h),
108
+ post: <T>(p: string, b: unknown, h?: Record<string, string>) => this.api<T>('POST', p, b, h),
109
+ del: <T>(p: string, h?: Record<string, string>) => this.api<T>('DELETE', p, undefined, h),
110
+ }
111
+ }
112
+
113
+ // ── identity ────────────────────────────────────────────────────────────────
114
+
115
+ /** The signed-in user's own identity (no orgs — those live in @baseworks/org). */
116
+ async me(): Promise<PublicUser> {
117
+ const { user } = await this.api<{ user: PublicUser }>('GET', '/me')
118
+ return user
119
+ }
120
+
121
+ /** Upsert an OIDC identity → the platform user. Called by auth-service (no auth). */
122
+ async sync(identity: OidcIdentity): Promise<{ id: string }> {
123
+ const { user } = await this.api<{ user: { id: string } }>('POST', '/sync', identity)
124
+ return user
125
+ }
126
+
127
+ /** Resolve a user id by exact email (invite flows). */
128
+ async lookupByEmail(email: string): Promise<PublicUser> {
129
+ const { user } = await this.api<{ user: PublicUser }>('GET', `/users/lookup?email=${encodeURIComponent(email)}`)
130
+ return user
131
+ }
132
+
133
+ // ── credentials (api tokens) ─────────────────────────────────────────────────
134
+
135
+ async issueToken(input: {
136
+ name: string
137
+ type?: TokenType
138
+ organizationId?: string | null
139
+ userId?: string | null
140
+ scopes?: string[]
141
+ expiresAt?: number | null
142
+ metadata?: Record<string, unknown>
143
+ }): Promise<IssuedToken> {
144
+ return this.api<IssuedToken>('POST', '/v1/tokens', input)
145
+ }
146
+
147
+ async listTokens(): Promise<ApiToken[]> {
148
+ const { tokens } = await this.api<{ tokens: ApiToken[] }>('GET', '/v1/tokens')
149
+ return tokens
150
+ }
151
+
152
+ async revokeToken(id: string): Promise<void> {
153
+ await this.api('DELETE', `/v1/tokens/${encodeURIComponent(id)}`)
154
+ }
155
+
156
+ /** Verify a raw token secret → its principal, or throw IamError(401). */
157
+ async verifyToken(secret: string): Promise<TokenPrincipal> {
158
+ const { principal } = await this.api<{ principal: TokenPrincipal }>('POST', '/v1/tokens/verify', { token: secret })
159
+ return principal
160
+ }
161
+ }
162
+
163
+ /** Factory used by the SDK capability registry. */
164
+ export function createIamClient(opts: IamClientOptions): IamClient {
165
+ return new IamClient(opts)
166
+ }
package/src/events.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { z } from 'zod'
2
+ import { defineAggregate } from '@baseworks/foldbase'
3
+
4
+ /**
5
+ * Event catalogs — the single source of truth for iam-service payloads.
6
+ * Shared by the service (typed emit) and tests. Two aggregates:
7
+ *
8
+ * user — a platform identity, keyed on (issuer, subject); id = user id
9
+ * api_token — a service/cli/agent credential; id = token id
10
+ *
11
+ * IAM is identity + credentials ONLY. Roles, memberships, orgs and the PDP live
12
+ * in @baseworks/org — a user has no org of their own, only memberships, so every
13
+ * role question is an org question (see iam-service/AGENTS.md).
14
+ *
15
+ * All timestamps are epoch millis carried in the payload (`at`) so folds stay
16
+ * deterministic. `scopes`/`metadata` are JSON strings the service serializes
17
+ * before emit (mirrors org-service's `metadata` convention).
18
+ */
19
+
20
+ // ── user ────────────────────────────────────────────────────────────────────
21
+
22
+ export const UserEvents = defineAggregate('user', {
23
+ // First sight of an OIDC identity (auth-service POST /sync). id = new user id.
24
+ UserSynced: z.object({
25
+ subject: z.string().min(1),
26
+ issuer: z.string().min(1),
27
+ email: z.string().min(1),
28
+ name: z.string().nullable(),
29
+ picture: z.string().nullable(),
30
+ at: z.number().int(),
31
+ }),
32
+ // A later /sync for the same identity refreshes the profile fields.
33
+ UserUpdated: z.object({
34
+ email: z.string().min(1),
35
+ name: z.string().nullable(),
36
+ picture: z.string().nullable(),
37
+ at: z.number().int(),
38
+ }),
39
+ })
40
+
41
+ // ── api_token ───────────────────────────────────────────────────────────────
42
+
43
+ export const TOKEN_TYPES = ['user', 'service', 'cli', 'agent'] as const
44
+ export const TokenType = z.enum(TOKEN_TYPES)
45
+ export type TokenType = z.infer<typeof TokenType>
46
+
47
+ export const ApiTokenEvents = defineAggregate('api_token', {
48
+ TokenIssued: z.object({
49
+ tokenType: TokenType,
50
+ // Opaque context — iam does NOT validate org membership (org owns that).
51
+ organizationId: z.string().nullable(),
52
+ userId: z.string().nullable(),
53
+ name: z.string().min(1),
54
+ tokenHash: z.string().min(1),
55
+ keyPrefix: z.string().min(1),
56
+ scopes: z.string(), // JSON string[]
57
+ metadata: z.string(), // JSON object
58
+ expiresAt: z.number().int().nullable(),
59
+ at: z.number().int(),
60
+ }),
61
+ // Throttled last-used stamp (not written on every request).
62
+ TokenUsed: z.object({
63
+ at: z.number().int(),
64
+ }),
65
+ // Revoke removes the read-model row; the event stays in the log.
66
+ TokenRevoked: z.object({}),
67
+ })
@@ -0,0 +1,55 @@
1
+ import { defineProjection, type PolicyDef } from '@baseworks/foldbase'
2
+ import { UserEvents, ApiTokenEvents } from './events.js'
3
+
4
+ /**
5
+ * Read-model definitions the service registers on boot (idempotent, like
6
+ * migrations). Columns are inferred from the event field types. `updated_at` is
7
+ * part of foldbase's read-model spine (server-maintained) so it is never
8
+ * declared; the stream id is the `id` column.
9
+ *
10
+ * The service is the authorization layer (route handlers enforce end-user
11
+ * scoping), so policies are permissive for the service's own `admin` identity.
12
+ */
13
+
14
+ export const readUsers = defineProjection('users', UserEvents, (on) => ({
15
+ UserSynced: on.UserSynced.upsert((e) => ({
16
+ subject: e.subject,
17
+ issuer: e.issuer,
18
+ email: e.email,
19
+ name: e.name,
20
+ picture: e.picture,
21
+ created_at: e.at,
22
+ })),
23
+ UserUpdated: on.UserUpdated.upsert((e) => ({
24
+ email: e.email,
25
+ name: e.name,
26
+ picture: e.picture,
27
+ })),
28
+ }))
29
+
30
+ export const readApiTokens = defineProjection('api_tokens', ApiTokenEvents, (on) => ({
31
+ TokenIssued: on.TokenIssued.upsert((e) => ({
32
+ token_type: e.tokenType,
33
+ organization_id: e.organizationId,
34
+ user_id: e.userId,
35
+ name: e.name,
36
+ token_hash: e.tokenHash,
37
+ key_prefix: e.keyPrefix,
38
+ scopes: e.scopes,
39
+ metadata: e.metadata,
40
+ expires_at: e.expiresAt,
41
+ created_at: e.at,
42
+ })),
43
+ TokenUsed: on.TokenUsed.upsert((e) => ({
44
+ last_used_at: e.at,
45
+ })),
46
+ TokenRevoked: on.TokenRevoked.delete(),
47
+ }))
48
+
49
+ export const PROJECTIONS = [readUsers, readApiTokens] as const
50
+
51
+ /** Permissive policies for the service's own `admin` identity. */
52
+ export const POLICIES: PolicyDef[] = [
53
+ { name: 'users', role: 'admin' },
54
+ { name: 'api_tokens', role: 'admin' },
55
+ ]
package/src/types.ts ADDED
@@ -0,0 +1,96 @@
1
+ // IAM domain types. Foldbase-native: read-model row shapes (snake_case, as
2
+ // stored) and REST DTOs (camelCase, as the API + CLI consume them).
3
+
4
+ import type { TokenType } from './events.js'
5
+ export type { TokenType } from './events.js'
6
+ export { TOKEN_TYPES } from './events.js'
7
+
8
+ // ── read-model rows (snake_case — foldbase read_<name> tables) ────────────────
9
+
10
+ export interface UserRow {
11
+ id: string
12
+ subject: string
13
+ issuer: string
14
+ email: string
15
+ name: string | null
16
+ picture: string | null
17
+ created_at: number
18
+ updated_at: number
19
+ }
20
+
21
+ export interface ApiTokenRow {
22
+ id: string
23
+ token_type: TokenType
24
+ organization_id: string | null
25
+ user_id: string | null
26
+ name: string
27
+ token_hash: string
28
+ key_prefix: string
29
+ scopes: string // JSON string[]
30
+ metadata: string // JSON object
31
+ expires_at: number | null
32
+ last_used_at: number | null
33
+ created_at: number
34
+ updated_at: number
35
+ }
36
+
37
+ // ── REST DTOs (camelCase — API + CLI) ─────────────────────────────────────────
38
+
39
+ export interface User {
40
+ id: string
41
+ subject: string
42
+ issuer: string
43
+ email: string
44
+ name: string | null
45
+ picture: string | null
46
+ createdAt: number
47
+ updatedAt: number
48
+ }
49
+
50
+ /** Public-safe identity projection (returned by /me and /users/lookup). */
51
+ export interface PublicUser {
52
+ id: string
53
+ email: string
54
+ name: string | null
55
+ }
56
+
57
+ /** An OIDC identity presented to POST /sync (before a user id exists). */
58
+ export interface OidcIdentity {
59
+ subject: string
60
+ issuer: string
61
+ email: string
62
+ name?: string | null
63
+ picture?: string | null
64
+ }
65
+
66
+ /** A token as listed — never carries the secret or its hash. */
67
+ export interface ApiToken {
68
+ id: string
69
+ type: TokenType
70
+ organizationId: string | null
71
+ userId: string | null
72
+ name: string
73
+ keyPrefix: string
74
+ scopes: string[]
75
+ metadata: Record<string, unknown>
76
+ expiresAt: number | null
77
+ lastUsedAt: number | null
78
+ createdAt: number
79
+ updatedAt: number
80
+ }
81
+
82
+ /** The one-time issue response — `token` is the secret, shown only here. */
83
+ export interface IssuedToken {
84
+ token: ApiToken
85
+ /** The full secret; surfaced exactly once at creation. */
86
+ secret: string
87
+ }
88
+
89
+ /** The identity a verified token resolves to. */
90
+ export interface TokenPrincipal {
91
+ tokenId: string
92
+ type: TokenType
93
+ organizationId: string | null
94
+ userId: string | null
95
+ scopes: string[]
96
+ }