@baseworks/iam 0.2.0 → 1.3.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/cli.js CHANGED
@@ -17,21 +17,10 @@ function buildIamCommand(deps) {
17
17
  ["id", user.id]
18
18
  ]);
19
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
20
  const token = iam.command("token").description("api tokens (service / cli / agent credentials)");
32
21
  outputOpt(token.command("list")).description("active tokens you can see").action(async (opts) => {
33
22
  const { tokens } = await http.get(
34
- "/v1/tokens"
23
+ "/tokens"
35
24
  ).catch(die);
36
25
  printOutput(
37
26
  tokens,
@@ -39,16 +28,16 @@ function buildIamCommand(deps) {
39
28
  () => table(tokens, [
40
29
  { key: "name", label: "NAME" },
41
30
  { key: "type", label: "TYPE" },
42
- { key: "keyPrefix", label: "PREFIX" },
31
+ { key: "key_prefix", label: "PREFIX" },
43
32
  { key: "id", label: "ID" }
44
33
  ])
45
34
  );
46
35
  });
47
36
  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", {
37
+ const res = await http.post("/tokens", {
49
38
  name,
50
39
  type: opts.type,
51
- organizationId: opts.org ?? null,
40
+ org_id: opts.org ?? null,
52
41
  scopes: opts.scope ?? []
53
42
  }).catch(die);
54
43
  success(`token issued: ${res.token.id}`);
@@ -58,7 +47,7 @@ function buildIamCommand(deps) {
58
47
  ]);
59
48
  });
60
49
  token.command("revoke <id>").description("revoke a token by id").action(async (id) => {
61
- await http.del(`/v1/tokens/${encodeURIComponent(id)}`).catch(die);
50
+ await http.del(`/tokens/${encodeURIComponent(id)}`).catch(die);
62
51
  success(`token revoked: ${id}`);
63
52
  });
64
53
  return iam;
package/dist/client.d.ts CHANGED
@@ -35,8 +35,8 @@ interface User {
35
35
  email: string;
36
36
  name: string | null;
37
37
  picture: string | null;
38
- createdAt: number;
39
- updatedAt: number;
38
+ created_at: number;
39
+ updated_at: number;
40
40
  }
41
41
  /** Public-safe identity projection (returned by /me and /users/lookup). */
42
42
  interface PublicUser {
@@ -56,16 +56,16 @@ interface OidcIdentity {
56
56
  interface ApiToken {
57
57
  id: string;
58
58
  type: TokenType;
59
- organizationId: string | null;
60
- userId: string | null;
59
+ org_id: string | null;
60
+ user_id: string | null;
61
61
  name: string;
62
- keyPrefix: string;
62
+ key_prefix: string;
63
63
  scopes: string[];
64
64
  metadata: Record<string, unknown>;
65
- expiresAt: number | null;
66
- lastUsedAt: number | null;
67
- createdAt: number;
68
- updatedAt: number;
65
+ expires_at: number | null;
66
+ last_used_at: number | null;
67
+ created_at: number;
68
+ updated_at: number;
69
69
  }
70
70
  /** The one-time issue response — `token` is the secret, shown only here. */
71
71
  interface IssuedToken {
@@ -75,11 +75,21 @@ interface IssuedToken {
75
75
  }
76
76
  /** The identity a verified token resolves to. */
77
77
  interface TokenPrincipal {
78
- tokenId: string;
78
+ token_id: string;
79
79
  type: TokenType;
80
- organizationId: string | null;
81
- userId: string | null;
80
+ org_id: string | null;
81
+ user_id: string | null;
82
82
  scopes: string[];
83
+ /** The name it was issued under, e.g. `notes-api@staging`. */
84
+ name: string;
85
+ /**
86
+ * What the issuer bound the token to, returned as it was stored. A service
87
+ * that issues tokens for its own workloads keeps the binding here (for
88
+ * example the scope and app a runtime token belongs to) and checks it on
89
+ * every verify, because iam keeps it opaque and never interprets it.
90
+ */
91
+ metadata: Record<string, unknown>;
92
+ expires_at: number | null;
83
93
  }
84
94
 
85
95
  /** Forwarded end-user identity (X-Auth-*) for a trusted service caller. */
@@ -135,16 +145,29 @@ declare class IamClient {
135
145
  }>;
136
146
  /** Resolve a user id by exact email (invite flows). */
137
147
  lookupByEmail(email: string): Promise<PublicUser>;
148
+ /**
149
+ * Resolve a list of user ids to public profiles, in one round trip.
150
+ *
151
+ * Ids that resolve to nobody are absent from the answer rather than an error:
152
+ * one deleted user must not break a page that is otherwise fine.
153
+ */
154
+ resolveUsers(ids: string[]): Promise<PublicUser[]>;
138
155
  issueToken(input: {
139
156
  name: string;
140
157
  type?: TokenType;
141
- organizationId?: string | null;
142
- userId?: string | null;
158
+ org_id?: string | null;
159
+ user_id?: string | null;
143
160
  scopes?: string[];
144
- expiresAt?: number | null;
161
+ expires_at?: number | null;
145
162
  metadata?: Record<string, unknown>;
146
163
  }): Promise<IssuedToken>;
147
- listTokens(): Promise<ApiToken[]>;
164
+ /**
165
+ * The caller's own tokens, or with `org_id` an organisation's — the latter
166
+ * only for a service named in the realm's IAM_ORG_TOKEN_ISSUERS.
167
+ */
168
+ listTokens(opts?: {
169
+ org_id?: string;
170
+ }): Promise<ApiToken[]>;
148
171
  revokeToken(id: string): Promise<void>;
149
172
  /** Verify a raw token secret → its principal, or throw IamError(401). */
150
173
  verifyToken(secret: string): Promise<TokenPrincipal>;
package/dist/client.js CHANGED
@@ -77,20 +77,36 @@ var IamClient = class _IamClient {
77
77
  const { user } = await this.api("GET", `/users/lookup?email=${encodeURIComponent(email)}`);
78
78
  return user;
79
79
  }
80
+ /**
81
+ * Resolve a list of user ids to public profiles, in one round trip.
82
+ *
83
+ * Ids that resolve to nobody are absent from the answer rather than an error:
84
+ * one deleted user must not break a page that is otherwise fine.
85
+ */
86
+ async resolveUsers(ids) {
87
+ if (ids.length === 0) return [];
88
+ const { users } = await this.api("POST", "/users/resolve", { ids });
89
+ return users;
90
+ }
80
91
  // ── credentials (api tokens) ─────────────────────────────────────────────────
81
92
  async issueToken(input) {
82
- return this.api("POST", "/v1/tokens", input);
93
+ return this.api("POST", "/tokens", input);
83
94
  }
84
- async listTokens() {
85
- const { tokens } = await this.api("GET", "/v1/tokens");
95
+ /**
96
+ * The caller's own tokens, or with `org_id` an organisation's — the latter
97
+ * only for a service named in the realm's IAM_ORG_TOKEN_ISSUERS.
98
+ */
99
+ async listTokens(opts = {}) {
100
+ const q = opts.org_id ? `?${new URLSearchParams({ org_id: opts.org_id })}` : "";
101
+ const { tokens } = await this.api("GET", `/tokens${q}`);
86
102
  return tokens;
87
103
  }
88
104
  async revokeToken(id) {
89
- await this.api("DELETE", `/v1/tokens/${encodeURIComponent(id)}`);
105
+ await this.api("DELETE", `/tokens/${encodeURIComponent(id)}`);
90
106
  }
91
107
  /** Verify a raw token secret → its principal, or throw IamError(401). */
92
108
  async verifyToken(secret) {
93
- const { principal } = await this.api("POST", "/v1/tokens/verify", { token: secret });
109
+ const { principal } = await this.api("POST", "/tokens/verify", { token: secret });
94
110
  return principal;
95
111
  }
96
112
  };
package/dist/events.d.ts CHANGED
@@ -25,17 +25,17 @@ declare const UserEvents: _baseworks_foldbase.EventCatalog<{
25
25
  picture: z.ZodNullable<z.ZodString>;
26
26
  at: z.ZodNumber;
27
27
  }, "strip", z.ZodTypeAny, {
28
- email: string;
29
28
  at: number;
30
29
  subject: string;
31
30
  issuer: string;
31
+ email: string;
32
32
  name: string | null;
33
33
  picture: string | null;
34
34
  }, {
35
- email: string;
36
35
  at: number;
37
36
  subject: string;
38
37
  issuer: string;
38
+ email: string;
39
39
  name: string | null;
40
40
  picture: string | null;
41
41
  }>;
@@ -45,13 +45,13 @@ declare const UserEvents: _baseworks_foldbase.EventCatalog<{
45
45
  picture: z.ZodNullable<z.ZodString>;
46
46
  at: z.ZodNumber;
47
47
  }, "strip", z.ZodTypeAny, {
48
- email: string;
49
48
  at: number;
49
+ email: string;
50
50
  name: string | null;
51
51
  picture: string | null;
52
52
  }, {
53
- email: string;
54
53
  at: number;
54
+ email: string;
55
55
  name: string | null;
56
56
  picture: string | null;
57
57
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baseworks/iam",
3
- "version": "0.2.0",
3
+ "version": "1.3.0",
4
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
5
  "type": "module",
6
6
  "publishConfig": {
@@ -27,15 +27,12 @@ describe('flect iam whoami', () => {
27
27
  })
28
28
  })
29
29
 
30
- // ── users find ────────────────────────────────────────────────────────────────
30
+ // ── no users find ─────────────────────────────────────────────────────────────
31
31
 
32
- describe('flect iam users find', () => {
33
- it('resolves a user id by email', async () => {
34
- mocks.mockGet('/users/lookup', { user: USER })
32
+ describe('flect iam users', () => {
33
+ it('is gone: looking a person up by email is for services, not for a CLI', async () => {
35
34
  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')
35
+ expect(r.stdout + r.stderr).toMatch(/unknown command/)
39
36
  })
40
37
  })
41
38
 
@@ -43,8 +40,8 @@ describe('flect iam users find', () => {
43
40
 
44
41
  describe('flect iam token', () => {
45
42
  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 }],
43
+ mocks.mockGet('/tokens', {
44
+ tokens: [{ id: 't1', name: 'ci', type: 'service', key_prefix: 'bwt_abc12345', expires_at: null }],
48
45
  })
49
46
  const r = await run(['iam', 'token', 'list'])
50
47
  expect(r.exitCode).toBe(0)
@@ -54,14 +51,14 @@ describe('flect iam token', () => {
54
51
  })
55
52
 
56
53
  it('issue prints the one-time secret', async () => {
57
- mocks.mockPost('/v1/tokens', { token: { id: 't1', keyPrefix: 'bwt_secret1234' }, secret: 'bwt_secret1234_rest' })
54
+ mocks.mockPost('/tokens', { token: { id: 't1', key_prefix: 'bwt_secret1234' }, secret: 'bwt_secret1234_rest' })
58
55
  const r = await run(['iam', 'token', 'issue', 'ci', '--type', 'service'])
59
56
  expect(r.exitCode).toBe(0)
60
57
  expect(r.stdout).toContain('bwt_secret1234_rest')
61
58
  })
62
59
 
63
60
  it('revoke calls DELETE and reports success', async () => {
64
- mocks.mockDelete('/v1/tokens/t1')
61
+ mocks.mockDelete('/tokens/t1')
65
62
  const r = await run(['iam', 'token', 'revoke', 't1'])
66
63
  expect(r.exitCode).toBe(0)
67
64
  expect(r.stdout).toContain('t1')
package/src/cli.ts CHANGED
@@ -41,24 +41,11 @@ export function buildIamCommand(deps: IamCliDeps): Command {
41
41
  ])
42
42
  })
43
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
- })
44
+ // There is no `users find <email>`. It asked iam "does this address have an
45
+ // account here?" with a person's token, which is an account-existence oracle;
46
+ // iam answers that to services only now. Invite by email instead
47
+ // (`org invite <email>`): the invitation reaches the person without anyone
48
+ // learning their user id first.
62
49
 
63
50
  // ── token (credentials) ───────────────────────────────────────────────────--
64
51
  const token = iam.command('token').description('api tokens (service / cli / agent credentials)')
@@ -67,15 +54,15 @@ export function buildIamCommand(deps: IamCliDeps): Command {
67
54
  .description('active tokens you can see')
68
55
  .action(async (opts: { output?: string }) => {
69
56
  const { tokens } = await http
70
- .get<{ tokens: Array<{ id: string; name: string; type: string; keyPrefix: string; expiresAt: number | null }> }>(
71
- '/v1/tokens',
57
+ .get<{ tokens: Array<{ id: string; name: string; type: string; key_prefix: string; expires_at: number | null }> }>(
58
+ '/tokens',
72
59
  )
73
60
  .catch(die)
74
61
  printOutput(tokens as unknown as Record<string, unknown>[], opts.output, () =>
75
62
  table(tokens as unknown as Record<string, unknown>[], [
76
63
  { key: 'name', label: 'NAME' },
77
64
  { key: 'type', label: 'TYPE' },
78
- { key: 'keyPrefix', label: 'PREFIX' },
65
+ { key: 'key_prefix', label: 'PREFIX' },
79
66
  { key: 'id', label: 'ID' },
80
67
  ]),
81
68
  )
@@ -89,10 +76,10 @@ export function buildIamCommand(deps: IamCliDeps): Command {
89
76
  .option('--scope <scope...>', 'grant scopes')
90
77
  .action(async (name: string, opts: { type?: string; org?: string; scope?: string[] }) => {
91
78
  const res = await http
92
- .post<{ token: { id: string; keyPrefix: string }; secret: string }>('/v1/tokens', {
79
+ .post<{ token: { id: string; key_prefix: string }; secret: string }>('/tokens', {
93
80
  name,
94
81
  type: opts.type,
95
- organizationId: opts.org ?? null,
82
+ org_id: opts.org ?? null,
96
83
  scopes: opts.scope ?? [],
97
84
  })
98
85
  .catch(die)
@@ -107,7 +94,7 @@ export function buildIamCommand(deps: IamCliDeps): Command {
107
94
  .command('revoke <id>')
108
95
  .description('revoke a token by id')
109
96
  .action(async (id: string) => {
110
- await http.del(`/v1/tokens/${encodeURIComponent(id)}`).catch(die)
97
+ await http.del(`/tokens/${encodeURIComponent(id)}`).catch(die)
111
98
  success(`token revoked: ${id}`)
112
99
  })
113
100
 
package/src/client.ts CHANGED
@@ -130,32 +130,49 @@ export class IamClient {
130
130
  return user
131
131
  }
132
132
 
133
+ /**
134
+ * Resolve a list of user ids to public profiles, in one round trip.
135
+ *
136
+ * Ids that resolve to nobody are absent from the answer rather than an error:
137
+ * one deleted user must not break a page that is otherwise fine.
138
+ */
139
+ async resolveUsers(ids: string[]): Promise<PublicUser[]> {
140
+ if (ids.length === 0) return []
141
+ const { users } = await this.api<{ users: PublicUser[] }>('POST', '/users/resolve', { ids })
142
+ return users
143
+ }
144
+
133
145
  // ── credentials (api tokens) ─────────────────────────────────────────────────
134
146
 
135
147
  async issueToken(input: {
136
148
  name: string
137
149
  type?: TokenType
138
- organizationId?: string | null
139
- userId?: string | null
150
+ org_id?: string | null
151
+ user_id?: string | null
140
152
  scopes?: string[]
141
- expiresAt?: number | null
153
+ expires_at?: number | null
142
154
  metadata?: Record<string, unknown>
143
155
  }): Promise<IssuedToken> {
144
- return this.api<IssuedToken>('POST', '/v1/tokens', input)
156
+ return this.api<IssuedToken>('POST', '/tokens', input)
145
157
  }
146
158
 
147
- async listTokens(): Promise<ApiToken[]> {
148
- const { tokens } = await this.api<{ tokens: ApiToken[] }>('GET', '/v1/tokens')
159
+ /**
160
+ * The caller's own tokens, or with `org_id` an organisation's — the latter
161
+ * only for a service named in the realm's IAM_ORG_TOKEN_ISSUERS.
162
+ */
163
+ async listTokens(opts: { org_id?: string } = {}): Promise<ApiToken[]> {
164
+ const q = opts.org_id ? `?${new URLSearchParams({ org_id: opts.org_id })}` : ''
165
+ const { tokens } = await this.api<{ tokens: ApiToken[] }>('GET', `/tokens${q}`)
149
166
  return tokens
150
167
  }
151
168
 
152
169
  async revokeToken(id: string): Promise<void> {
153
- await this.api('DELETE', `/v1/tokens/${encodeURIComponent(id)}`)
170
+ await this.api('DELETE', `/tokens/${encodeURIComponent(id)}`)
154
171
  }
155
172
 
156
173
  /** Verify a raw token secret → its principal, or throw IamError(401). */
157
174
  async verifyToken(secret: string): Promise<TokenPrincipal> {
158
- const { principal } = await this.api<{ principal: TokenPrincipal }>('POST', '/v1/tokens/verify', { token: secret })
175
+ const { principal } = await this.api<{ principal: TokenPrincipal }>('POST', '/tokens/verify', { token: secret })
159
176
  return principal
160
177
  }
161
178
  }
package/src/types.ts CHANGED
@@ -34,7 +34,11 @@ export interface ApiTokenRow {
34
34
  updated_at: number
35
35
  }
36
36
 
37
- // ── REST DTOs (camelCase — API + CLI) ─────────────────────────────────────────
37
+ // ── REST DTOs ─────────────────────────────────────────────────────────────────
38
+ //
39
+ // snake_case on the wire (ADR-043), matching the row names above. The mapping
40
+ // from row to DTO now only does real work — parsing JSON columns, hiding the
41
+ // hash — instead of also renaming every field.
38
42
 
39
43
  export interface User {
40
44
  id: string
@@ -43,8 +47,8 @@ export interface User {
43
47
  email: string
44
48
  name: string | null
45
49
  picture: string | null
46
- createdAt: number
47
- updatedAt: number
50
+ created_at: number
51
+ updated_at: number
48
52
  }
49
53
 
50
54
  /** Public-safe identity projection (returned by /me and /users/lookup). */
@@ -67,16 +71,16 @@ export interface OidcIdentity {
67
71
  export interface ApiToken {
68
72
  id: string
69
73
  type: TokenType
70
- organizationId: string | null
71
- userId: string | null
74
+ org_id: string | null
75
+ user_id: string | null
72
76
  name: string
73
- keyPrefix: string
77
+ key_prefix: string
74
78
  scopes: string[]
75
79
  metadata: Record<string, unknown>
76
- expiresAt: number | null
77
- lastUsedAt: number | null
78
- createdAt: number
79
- updatedAt: number
80
+ expires_at: number | null
81
+ last_used_at: number | null
82
+ created_at: number
83
+ updated_at: number
80
84
  }
81
85
 
82
86
  /** The one-time issue response — `token` is the secret, shown only here. */
@@ -88,9 +92,19 @@ export interface IssuedToken {
88
92
 
89
93
  /** The identity a verified token resolves to. */
90
94
  export interface TokenPrincipal {
91
- tokenId: string
95
+ token_id: string
92
96
  type: TokenType
93
- organizationId: string | null
94
- userId: string | null
97
+ org_id: string | null
98
+ user_id: string | null
95
99
  scopes: string[]
100
+ /** The name it was issued under, e.g. `notes-api@staging`. */
101
+ name: string
102
+ /**
103
+ * What the issuer bound the token to, returned as it was stored. A service
104
+ * that issues tokens for its own workloads keeps the binding here (for
105
+ * example the scope and app a runtime token belongs to) and checks it on
106
+ * every verify, because iam keeps it opaque and never interprets it.
107
+ */
108
+ metadata: Record<string, unknown>
109
+ expires_at: number | null
96
110
  }