@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.
- package/dist/chunk-26XPWBFV.js +53 -0
- package/dist/cli.d.ts +21 -0
- package/dist/cli.js +68 -0
- package/dist/client.d.ts +155 -0
- package/dist/client.js +105 -0
- package/dist/events.d.ts +111 -0
- package/dist/events.js +12 -0
- package/dist/projections.d.ts +19 -0
- package/dist/projections.js +51 -0
- package/package.json +39 -0
- package/src/__tests__/cli.test.ts +69 -0
- package/src/__tests__/helpers.ts +24 -0
- package/src/cli.ts +115 -0
- package/src/client.ts +166 -0
- package/src/events.ts +67 -0
- package/src/projections.ts +55 -0
- package/src/types.ts +96 -0
|
@@ -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
|
+
};
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/events.d.ts
ADDED
|
@@ -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,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
|
+
}
|