@jini-ai/user-management 0.3.3 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/dist/admin/adapters/memory.d.ts +3 -5
  3. package/dist/admin/adapters/memory.js +27 -28
  4. package/dist/admin/conformance/index.js +1 -0
  5. package/dist/admin/controllers/auth.controller.js +8 -6
  6. package/dist/admin/controllers/members.controller.js +9 -6
  7. package/dist/admin/controllers/roles.controller.js +99 -105
  8. package/dist/admin/controllers/users.controller.js +56 -36
  9. package/dist/admin/error-copy.d.ts +11 -0
  10. package/dist/admin/error-copy.js +10 -0
  11. package/dist/admin/people.rules.js +37 -31
  12. package/dist/admin/react/components/MemberRow.d.ts +1 -1
  13. package/dist/admin/react/components/MemberRow.js +12 -2
  14. package/dist/admin/react/hooks/AuthBinding.hooks.d.ts +6 -21
  15. package/dist/admin/react/hooks/AuthBinding.hooks.js +3 -8
  16. package/dist/admin/react/hooks/AuthScope.hooks.d.ts +22 -0
  17. package/dist/admin/react/hooks/AuthScope.hooks.js +10 -0
  18. package/dist/admin/react/hooks/LoginPage.hooks.d.ts +1 -1
  19. package/dist/admin/react/hooks/LoginPage.hooks.js +1 -1
  20. package/dist/admin/react/hooks/MembersBinding.hooks.d.ts +6 -17
  21. package/dist/admin/react/hooks/MembersBinding.hooks.js +3 -8
  22. package/dist/admin/react/hooks/MembersPage.hooks.d.ts +1 -1
  23. package/dist/admin/react/hooks/MembersPage.hooks.js +1 -1
  24. package/dist/admin/react/hooks/MembersScope.hooks.d.ts +17 -0
  25. package/dist/admin/react/hooks/MembersScope.hooks.js +10 -0
  26. package/dist/admin/react/hooks/RolesBinding.hooks.d.ts +7 -21
  27. package/dist/admin/react/hooks/RolesBinding.hooks.js +3 -8
  28. package/dist/admin/react/hooks/RolesController.hooks.js +1 -1
  29. package/dist/admin/react/hooks/RolesPage.hooks.d.ts +2 -2
  30. package/dist/admin/react/hooks/RolesPage.hooks.js +22 -8
  31. package/dist/admin/react/hooks/RolesScope.hooks.d.ts +21 -0
  32. package/dist/admin/react/hooks/RolesScope.hooks.js +10 -0
  33. package/dist/admin/react/hooks/UsersBinding.hooks.d.ts +6 -18
  34. package/dist/admin/react/hooks/UsersBinding.hooks.js +3 -8
  35. package/dist/admin/react/hooks/UsersPage.hooks.d.ts +1 -1
  36. package/dist/admin/react/hooks/UsersPage.hooks.js +1 -1
  37. package/dist/admin/react/hooks/UsersScope.hooks.d.ts +19 -0
  38. package/dist/admin/react/hooks/UsersScope.hooks.js +10 -0
  39. package/dist/admin/react/hooks/UsersTab.hooks.d.ts +6 -0
  40. package/dist/admin/react/hooks/UsersTab.hooks.js +34 -22
  41. package/dist/admin/react/index.d.ts +4 -4
  42. package/dist/admin/react/pages/LoginPage.d.ts +1 -1
  43. package/dist/admin/react/pages/MembersPage.d.ts +1 -1
  44. package/dist/admin/react/pages/RolesPage.d.ts +2 -2
  45. package/dist/admin/react/pages/RolesPage.js +6 -2
  46. package/dist/admin/react/pages/UsersPage.d.ts +2 -2
  47. package/dist/admin/react/pages/UsersPage.js +6 -2
  48. package/dist/admin/rules.js +14 -12
  49. package/dist/react/errors.js +16 -9
  50. package/dist/react/index.d.ts +1 -1
  51. package/dist/react/members/hooks/use-members.hooks.js +21 -12
  52. package/dist/react/testing.js +4 -6
  53. package/dist/react/users/UserRowParts.d.ts +53 -0
  54. package/dist/react/users/UserRowParts.js +39 -0
  55. package/dist/react/users/Users.d.ts +31 -2
  56. package/dist/react/users/Users.js +41 -36
  57. package/dist/react/users/hooks/use-users.hooks.js +57 -30
  58. package/dist/server/admin-crud-service.js +25 -13
  59. package/dist/server/agent-tool-input.js +62 -36
  60. package/dist/server/agent-tool-registrations.d.ts +46 -0
  61. package/dist/server/agent-tool-registrations.js +28 -20
  62. package/dist/server/auth-service.js +35 -20
  63. package/dist/server/authorize.js +7 -9
  64. package/dist/server/permission-migrations.js +35 -25
  65. package/dist/server/repo.memory-transactions.js +5 -6
  66. package/dist/server/repo.memory.js +35 -81
  67. package/dist/server/seed.js +100 -84
  68. package/package.json +12 -12
@@ -33,6 +33,9 @@ const STRING_KEYWORDS = new Set(["type", "minLength", "description"]);
33
33
  * that ordering a schema could publish, say, a `pattern` the parser silently ignores, and every
34
34
  * test using a conforming fixture would still pass.
35
35
  *
36
+ * Check order (pinned in `__tests__/agent-tool-input.pins.test.ts`): object shape, top-level
37
+ * keywords, `type`, `additionalProperties`, `properties`, `required`, then each property sub-schema.
38
+ *
36
39
  * @param schema - A tool's published `inputSchema`.
37
40
  * @throws {Error} If the schema, or any property sub-schema, uses an unimplemented keyword.
38
41
  * @complexity O(k) in the total keyword count across the schema and its property sub-schemas.
@@ -51,27 +54,42 @@ function assertSchemaIsInterpretable(schema) {
51
54
  const properties = Object.hasOwn(schema, "properties") ? schema.properties : {};
52
55
  if (!isSchemaObject(properties))
53
56
  throw new Error("identity agent-tool schema properties must be an object");
54
- const required = Object.hasOwn(schema, "required") ? schema.required : [];
55
- if (!Array.isArray(required) || required.some(key => typeof key !== "string") || new Set(required).size !== required.length) {
57
+ assertRequiredIsDeclared(Object.hasOwn(schema, "required") ? schema.required : [], properties);
58
+ for (const [key, sub] of Object.entries(properties))
59
+ assertStringPropertySchema(key, sub);
60
+ }
61
+ /** `required` must be unique strings, each naming an OWN declared property. */
62
+ function assertRequiredIsDeclared(required, properties) {
63
+ if (!isUniqueStringArray(required)) {
56
64
  throw new Error("identity agent-tool schema required must be an array of unique strings");
57
65
  }
58
66
  for (const key of required) {
59
67
  if (!Object.hasOwn(properties, key))
60
68
  throw new Error(`identity agent-tool schema requires undeclared property '${key}'`);
61
69
  }
62
- for (const [key, sub] of Object.entries(properties)) {
63
- if (!isSchemaObject(sub))
64
- throw new Error(`identity agent-tool schema property '${key}' must be an object`);
65
- assertKeywords(sub, STRING_KEYWORDS);
66
- if (sub.type !== "string") {
67
- throw new Error(`identity agent-tool schema declares non-string property '${key}' — this parser only implements string properties`);
68
- }
69
- if (Object.hasOwn(sub, "minLength") && (typeof sub.minLength !== "number" || !Number.isInteger(sub.minLength) || sub.minLength < 0)) {
70
- throw new Error(`identity agent-tool schema minLength for '${key}' must be a nonnegative integer`);
71
- }
70
+ }
71
+ function isUniqueStringArray(value) {
72
+ return Array.isArray(value) && value.every(key => typeof key === "string") && new Set(value).size === value.length;
73
+ }
74
+ /** A property sub-schema must be a string schema whose optional `minLength` is a nonnegative integer. */
75
+ function assertStringPropertySchema(key, sub) {
76
+ if (!isSchemaObject(sub))
77
+ throw new Error(`identity agent-tool schema property '${key}' must be an object`);
78
+ assertKeywords(sub, STRING_KEYWORDS);
79
+ if (sub.type !== "string") {
80
+ throw new Error(`identity agent-tool schema declares non-string property '${key}' — this parser only implements string properties`);
81
+ }
82
+ if (Object.hasOwn(sub, "minLength") && !isNonNegativeInteger(sub.minLength)) {
83
+ throw new Error(`identity agent-tool schema minLength for '${key}' must be a nonnegative integer`);
72
84
  }
73
85
  }
74
- /** JSON Schema objects and keyword placement are checked before inspecting any input value. */
86
+ function isNonNegativeInteger(value) {
87
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
88
+ }
89
+ /**
90
+ * JSON Schema objects and keyword placement are checked before inspecting any input value. The
91
+ * same "non-null, non-array object" test also decides whether an input is an object at all.
92
+ */
75
93
  function isSchemaObject(value) {
76
94
  return typeof value === "object" && value !== null && !Array.isArray(value);
77
95
  }
@@ -110,39 +128,47 @@ function assertKeywords(schema, supported) {
110
128
  */
111
129
  export function parseIdentityToolInput(required) {
112
130
  const schema = required.schema;
131
+ // Also guarantees `type === "object"` and `additionalProperties === false`, read through the
132
+ // same property access used below, so neither needs re-checking here.
113
133
  assertSchemaIsInterpretable(schema);
114
- if (schema.type !== "object") {
115
- throw new Error(`identity agent-tool schema must be an object schema, got type '${String(schema.type)}'`);
116
- }
117
134
  const { input } = required;
118
- if (typeof input !== "object" || input === null || Array.isArray(input)) {
119
- return { ok: false, error: new Error("input must be an object") };
120
- }
121
- const record = input;
135
+ if (!isSchemaObject(input))
136
+ return rejected("input must be an object");
137
+ // Read through ordinary property access, exactly as before validation was split out — see the
138
+ // "inherited schema members (reported, not fixed)" pins for what that means for a prototype.
122
139
  const properties = (schema.properties ?? {});
123
- if (schema.additionalProperties === false) {
124
- for (const key of Object.keys(record)) {
125
- if (!Object.hasOwn(properties, key)) {
126
- return { ok: false, error: new Error(`'${key}' is not a recognized input key`) };
127
- }
128
- }
129
- }
130
- for (const key of schema.required ?? []) {
131
- if (!Object.hasOwn(record, key) || record[key] === undefined) {
132
- return { ok: false, error: new Error(`'${key}' is required`) };
133
- }
134
- }
140
+ const violation = findUnrecognizedKey(input, properties)
141
+ ?? findMissingRequiredKey(input, schema.required ?? []);
142
+ if (violation !== undefined)
143
+ return rejected(violation);
144
+ return extractStrings(input, properties);
145
+ }
146
+ function rejected(message) {
147
+ return { ok: false, error: new Error(message) };
148
+ }
149
+ /** Own input keys only; a key inherited through the input's prototype is neither rejected nor read. */
150
+ function findUnrecognizedKey(record, properties) {
151
+ const key = Object.keys(record).find(candidate => !Object.hasOwn(properties, candidate));
152
+ return key === undefined ? undefined : `'${key}' is not a recognized input key`;
153
+ }
154
+ /** A required key present with the value `undefined` counts as missing. */
155
+ function findMissingRequiredKey(record, requiredKeys) {
156
+ const key = requiredKeys.find(candidate => !Object.hasOwn(record, candidate) || record[candidate] === undefined);
157
+ return key === undefined ? undefined : `'${key}' is required`;
158
+ }
159
+ /** Extracts every present declared key, in declaration order, rejecting the first non-conforming value. */
160
+ function extractStrings(record, properties) {
135
161
  const value = {};
136
162
  for (const [key, sub] of Object.entries(properties)) {
137
163
  if (!Object.hasOwn(record, key))
138
164
  continue;
139
165
  const candidate = record[key];
140
- if (typeof candidate !== "string") {
141
- return { ok: false, error: new Error(`'${key}' must be a string, received ${describeType(candidate)}`) };
142
- }
166
+ if (typeof candidate !== "string")
167
+ return rejected(`'${key}' must be a string, received ${describeType(candidate)}`);
143
168
  if (typeof sub.minLength === "number" && Array.from(candidate).length < sub.minLength) {
144
- return { ok: false, error: new Error(`'${key}' must contain at least ${sub.minLength} Unicode code point(s)`) };
169
+ return rejected(`'${key}' must contain at least ${sub.minLength} Unicode code point(s)`);
145
170
  }
171
+ // defineProperty keeps a `__proto__` key as plain own data rather than invoking the setter.
146
172
  Object.defineProperty(value, key, { value: candidate, enumerable: true, writable: true, configurable: true });
147
173
  }
148
174
  return { ok: true, value };
@@ -13,6 +13,7 @@ import type { Clock, IdGenerator } from "@jini-ai/core/primitives";
13
13
  * `assistant/__tests__/tool-registrations.identity-authorization.test.ts`.
14
14
  */
15
15
  import { type DerivedRiskByToolId, type ToolRegistration } from "@jini-ai/core";
16
+ import { type IdentityAgentToolDefinition } from "./agent-tools.js";
16
17
  import type { AuthServiceDeps } from "./auth-service.js";
17
18
  import type { IdentityRepos, PasswordHasherPort, PolicyPermissionRepoPort, PolicyRepoPort, PrincipalPolicyRepoPort, PrincipalRepoPort, PrincipalRoleRepoPort, RolePolicyRepoPort, RoleRepoPort, SessionRepoPort, UserRepoPort } from "../core/index.js";
18
19
  /**
@@ -48,5 +49,50 @@ export interface IdentityToolDeps {
48
49
  * `sideEffects` declaration.
49
50
  */
50
51
  export declare const identityDerivedRisk: DerivedRiskByToolId;
52
+ /**
53
+ * Catalog lookup that fails loudly on drift between a handler's tool id and the published catalog.
54
+ * Exported for this module's tests only — the package entry does not re-export it.
55
+ */
56
+ export declare function requireIdentityCatalogEntry({ toolId }: {
57
+ toolId: string;
58
+ }): IdentityAgentToolDefinition;
59
+ /**
60
+ * Validates `input` through the tool's OWN published `inputSchema` (interpreted by
61
+ * `agent-tool-input.ts`), then re-throws any rejection with that schema appended.
62
+ *
63
+ * Identity is the one domain whose published schema is ALSO the enforcing parser, so this does not
64
+ * use the kit's `withSchemaOnRejection`: there is no domain call to wrap, the rejection is produced
65
+ * here. Same error-recovery contract either way — a model that gets back only "'roleId' is
66
+ * required" must guess the rest of the shape, whereas one that gets the schema with it can correct
67
+ * the call in a single turn.
68
+ *
69
+ * @complexity O(p) in the tool's declared property count.
70
+ * @overallScore 100
71
+ */
72
+ /**
73
+ * A validated tool input, with required and optional reads spelled differently.
74
+ *
75
+ * The distinction is not decoration. `parseIdentityToolInput` returns a `Record<string, string>`
76
+ * whose keys are whatever the caller supplied and the schema accepted, so nothing in the type says
77
+ * which fields are guaranteed. Reading them all the same way makes a schema that forgot to mark a
78
+ * field `required` indistinguishable from one that did — and the failure mode is silent: the
79
+ * handler passes `undefined` into a service call, which stores it. Splitting the read means the
80
+ * handler states which fields it is relying on, and a schema/handler disagreement throws at the
81
+ * boundary naming the field.
82
+ */
83
+ export interface ValidatedIdentityInput {
84
+ /** A field the tool's published `inputSchema` marks `required`. @throws {Error} If absent. */
85
+ req(field: string): string;
86
+ /** A field the schema leaves optional. `undefined` means the caller omitted it. */
87
+ opt(field: string): string | undefined;
88
+ }
89
+ /**
90
+ * Parses `input` against `toolId`'s published schema and returns the split required/optional reader.
91
+ * Exported for this module's tests only — the package entry does not re-export it.
92
+ */
93
+ export declare function identityInput({ toolId, input }: {
94
+ toolId: string;
95
+ input: unknown;
96
+ }): ValidatedIdentityInput;
51
97
  export declare function buildIdentityRegistrations(routeDeps: IdentityToolDeps): ToolRegistration[];
52
98
  //# sourceMappingURL=agent-tool-registrations.d.ts.map
@@ -102,14 +102,22 @@ export const identityDerivedRisk = new Map([
102
102
  * work with what it got and knows not to treat the list as complete.
103
103
  */
104
104
  const IDENTITY_USER_LIST_MAX = 200;
105
- function requireIdentityCatalogEntry(toolId) {
105
+ /**
106
+ * Catalog lookup that fails loudly on drift between a handler's tool id and the published catalog.
107
+ * Exported for this module's tests only — the package entry does not re-export it.
108
+ */
109
+ export function requireIdentityCatalogEntry({ toolId }) {
106
110
  const entry = CATALOG_BY_ID.get(toolId);
107
111
  if (!entry)
108
112
  throw new Error(`agent-tool-registrations: catalog has no entry named '${toolId}' — @jini-ai/user-management/server catalog drifted`);
109
113
  return entry;
110
114
  }
111
- function identityInput(toolId, input) {
112
- const entry = requireIdentityCatalogEntry(toolId);
115
+ /**
116
+ * Parses `input` against `toolId`'s published schema and returns the split required/optional reader.
117
+ * Exported for this module's tests only — the package entry does not re-export it.
118
+ */
119
+ export function identityInput({ toolId, input }) {
120
+ const entry = requireIdentityCatalogEntry({ toolId });
113
121
  const parsed = parseIdentityToolInput({ schema: entry.inputSchema, input });
114
122
  if (!parsed.ok) {
115
123
  const recovery = "Fix the input and retry — this will not resolve on retry without an input change.";
@@ -131,7 +139,7 @@ function identityInput(toolId, input) {
131
139
  }
132
140
  /** The permission set a tool's catalog entry declares — both halves when the gate is an OR. */
133
141
  function identityPermissionsFor(toolId) {
134
- const { authorization } = requireIdentityCatalogEntry(toolId);
142
+ const { authorization } = requireIdentityCatalogEntry({ toolId });
135
143
  return authorization.orPermission ? [authorization.permission, authorization.orPermission] : [authorization.permission];
136
144
  }
137
145
  /**
@@ -225,7 +233,7 @@ export function buildIdentityRegistrations(routeDeps) {
225
233
  const serviceDeps = () => identityServiceDepsFrom(routeDeps);
226
234
  const handlers = {
227
235
  identity_user_list: async (ctx) => {
228
- identityInput("identity_user_list", ctx.input);
236
+ identityInput({ toolId: "identity_user_list", input: ctx.input });
229
237
  await assertIdentityReadAllowed(routeDeps, "identity_user_list", ctx.principal.id);
230
238
  const principals = await routeDeps.principalRepo.list({ workspaceId: routeDeps.workspaceId });
231
239
  // Humans only — `system`/`agent`/`api_key` principals (including the disabled `user-local`
@@ -244,19 +252,19 @@ export function buildIdentityRegistrations(routeDeps) {
244
252
  : { users: listed };
245
253
  },
246
254
  identity_role_list: async (ctx) => {
247
- identityInput("identity_role_list", ctx.input);
255
+ identityInput({ toolId: "identity_role_list", input: ctx.input });
248
256
  await assertIdentityReadAllowed(routeDeps, "identity_role_list", ctx.principal.id);
249
257
  const roles = await routeDeps.roleRepo.list({ workspaceId: routeDeps.workspaceId });
250
258
  return { roles: roles.map(toIdentityRoleView) };
251
259
  },
252
260
  identity_policy_list: async (ctx) => {
253
- identityInput("identity_policy_list", ctx.input);
261
+ identityInput({ toolId: "identity_policy_list", input: ctx.input });
254
262
  await assertIdentityReadAllowed(routeDeps, "identity_policy_list", ctx.principal.id);
255
263
  const policies = await routeDeps.policyRepo.list({ workspaceId: routeDeps.workspaceId });
256
264
  return { policies: policies.map(toIdentityPolicyView) };
257
265
  },
258
266
  identity_user_create: async (ctx) => {
259
- const input = identityInput("identity_user_create", ctx.input);
267
+ const input = identityInput({ toolId: "identity_user_create", input: ctx.input });
260
268
  const { principal, user } = await createUser({
261
269
  deps: serviceDeps(),
262
270
  input: {
@@ -270,7 +278,7 @@ export function buildIdentityRegistrations(routeDeps) {
270
278
  return { user: toIdentityUserView({ principal, user, roleIds: [] }) };
271
279
  },
272
280
  identity_user_update_email: async (ctx) => {
273
- const input = identityInput("identity_user_update_email", ctx.input);
281
+ const input = identityInput({ toolId: "identity_user_update_email", input: ctx.input });
274
282
  const { user } = await updateUser({
275
283
  deps: serviceDeps(),
276
284
  input: {
@@ -287,7 +295,7 @@ export function buildIdentityRegistrations(routeDeps) {
287
295
  return { user: toIdentityUserView({ principal, user, roleIds: await roleIdsFor(routeDeps, principal.id) }) };
288
296
  },
289
297
  identity_user_change_username: async (ctx) => {
290
- const input = identityInput("identity_user_change_username", ctx.input);
298
+ const input = identityInput({ toolId: "identity_user_change_username", input: ctx.input });
291
299
  const principalId = input.req("principalId");
292
300
  const { user } = await updateUser({ deps: serviceDeps(), input: {
293
301
  workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, principalId,
@@ -298,7 +306,7 @@ export function buildIdentityRegistrations(routeDeps) {
298
306
  return { user: toIdentityUserView({ principal, user, roleIds: await roleIdsFor(routeDeps, principalId) }) };
299
307
  },
300
308
  identity_user_disable: async (ctx) => {
301
- const input = identityInput("identity_user_disable", ctx.input);
309
+ const input = identityInput({ toolId: "identity_user_disable", input: ctx.input });
302
310
  const { principal } = await disablePrincipal({
303
311
  deps: serviceDeps(),
304
312
  input: {
@@ -314,7 +322,7 @@ export function buildIdentityRegistrations(routeDeps) {
314
322
  return { user: toIdentityUserView({ principal, user, roleIds: await roleIdsFor(routeDeps, principal.id) }) };
315
323
  },
316
324
  identity_user_enable: async (ctx) => {
317
- const input = identityInput("identity_user_enable", ctx.input);
325
+ const input = identityInput({ toolId: "identity_user_enable", input: ctx.input });
318
326
  const { principal } = await enablePrincipal({
319
327
  deps: serviceDeps(),
320
328
  input: { workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, principalId: input.req("principalId") },
@@ -323,7 +331,7 @@ export function buildIdentityRegistrations(routeDeps) {
323
331
  return { user: toIdentityUserView({ principal, user, roleIds: await roleIdsFor(routeDeps, principal.id) }) };
324
332
  },
325
333
  identity_role_create: async (ctx) => {
326
- const input = identityInput("identity_role_create", ctx.input);
334
+ const input = identityInput({ toolId: "identity_role_create", input: ctx.input });
327
335
  const { role } = await createRole({
328
336
  deps: serviceDeps(),
329
337
  input: { workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, name: input.req("name") },
@@ -331,7 +339,7 @@ export function buildIdentityRegistrations(routeDeps) {
331
339
  return { role: toIdentityRoleView(role) };
332
340
  },
333
341
  identity_role_assign: async (ctx) => {
334
- const input = identityInput("identity_role_assign", ctx.input);
342
+ const input = identityInput({ toolId: "identity_role_assign", input: ctx.input });
335
343
  const { assignment } = await assignRole({
336
344
  deps: serviceDeps(),
337
345
  input: {
@@ -346,7 +354,7 @@ export function buildIdentityRegistrations(routeDeps) {
346
354
  return { assigned: { principalId: assignment.principalId, roleId: assignment.roleId } };
347
355
  },
348
356
  identity_role_rename: async (ctx) => {
349
- const input = identityInput("identity_role_rename", ctx.input);
357
+ const input = identityInput({ toolId: "identity_role_rename", input: ctx.input });
350
358
  const { role } = await updateRole({
351
359
  deps: serviceDeps(),
352
360
  input: { workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, roleId: input.req("roleId"), name: input.req("name") },
@@ -354,7 +362,7 @@ export function buildIdentityRegistrations(routeDeps) {
354
362
  return { role: toIdentityRoleView(role) };
355
363
  },
356
364
  identity_role_delete: async (ctx) => {
357
- const input = identityInput("identity_role_delete", ctx.input);
365
+ const input = identityInput({ toolId: "identity_role_delete", input: ctx.input });
358
366
  await deleteRole({
359
367
  deps: serviceDeps(),
360
368
  input: { workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, roleId: input.req("roleId") },
@@ -364,7 +372,7 @@ export function buildIdentityRegistrations(routeDeps) {
364
372
  return { deleted: { roleId: input.req("roleId") } };
365
373
  },
366
374
  identity_policy_create: async (ctx) => {
367
- const input = identityInput("identity_policy_create", ctx.input);
375
+ const input = identityInput({ toolId: "identity_policy_create", input: ctx.input });
368
376
  const { policy } = await createPolicy({
369
377
  deps: serviceDeps(),
370
378
  input: {
@@ -379,7 +387,7 @@ export function buildIdentityRegistrations(routeDeps) {
379
387
  return { policy: toIdentityPolicyView(policy) };
380
388
  },
381
389
  identity_policy_update: async (ctx) => {
382
- const input = identityInput("identity_policy_update", ctx.input);
390
+ const input = identityInput({ toolId: "identity_policy_update", input: ctx.input });
383
391
  const { policy } = await updatePolicy({
384
392
  deps: serviceDeps(),
385
393
  input: {
@@ -393,7 +401,7 @@ export function buildIdentityRegistrations(routeDeps) {
393
401
  return { policy: toIdentityPolicyView(policy) };
394
402
  },
395
403
  identity_policy_delete: async (ctx) => {
396
- const input = identityInput("identity_policy_delete", ctx.input);
404
+ const input = identityInput({ toolId: "identity_policy_delete", input: ctx.input });
397
405
  await deletePolicy({
398
406
  deps: serviceDeps(),
399
407
  input: { workspaceId: routeDeps.workspaceId, callerPrincipalId: ctx.principal.id, policyId: input.req("policyId") },
@@ -403,7 +411,7 @@ export function buildIdentityRegistrations(routeDeps) {
403
411
  return { deleted: { policyId: input.req("policyId") } };
404
412
  },
405
413
  identity_policy_attach: async (ctx) => {
406
- const input = identityInput("identity_policy_attach", ctx.input);
414
+ const input = identityInput({ toolId: "identity_policy_attach", input: ctx.input });
407
415
  const { attachment } = await attachPolicy({
408
416
  deps: serviceDeps(),
409
417
  input: {
@@ -63,6 +63,38 @@ export async function createSessionForPrincipal(required, optional = {}) {
63
63
  await deps.repos.sessions.save(session);
64
64
  return { session, rawToken };
65
65
  }
66
+ /**
67
+ * The one status that may log in or keep a live session: the principal exists AND is `active`.
68
+ * Shared by `login` and `validateSession` so the two fail-closed checks cannot drift apart.
69
+ */
70
+ function isActivePrincipal(principal) {
71
+ return principal?.status === "active";
72
+ }
73
+ /**
74
+ * The credential checks of `login`, in their fixed order: blank input is refused before any lookup,
75
+ * and the (slow) hash verify runs only once a live principal is found. Every refusal throws the same
76
+ * `AuthInvalidCredentialsError` message — no user-enumeration signal.
77
+ */
78
+ async function verifyCredentials(required) {
79
+ const { deps, input, invalidCredentialsMessage } = required;
80
+ const refuse = () => new AuthInvalidCredentialsError({ message: invalidCredentialsMessage });
81
+ const username = normalizeUsername({ raw: input.username });
82
+ if (!username || !input.password)
83
+ throw refuse();
84
+ const userRow = await deps.repos.users.findByUsername({ workspaceId: input.workspaceId, username });
85
+ if (!userRow)
86
+ throw refuse();
87
+ const principal = await deps.repos.principals.findById({
88
+ workspaceId: input.workspaceId,
89
+ id: userRow.principalId,
90
+ });
91
+ if (!isActivePrincipal(principal))
92
+ throw refuse();
93
+ const passwordOk = await deps.hasher.verify({ hash: userRow.passwordHash, password: input.password });
94
+ if (!passwordOk)
95
+ throw refuse();
96
+ return { userRow, principal };
97
+ }
66
98
  /**
67
99
  * Verify `username`+`password` for an `active` principal and mint a session
68
100
  * Constant-shaped failure: a nonexistent username, a
@@ -77,25 +109,8 @@ export async function createSessionForPrincipal(required, optional = {}) {
77
109
  export async function login(required, optional = {}) {
78
110
  const { deps } = required;
79
111
  const input = { ...required.input, ...optional };
80
- const username = normalizeUsername({ raw: input.username });
81
- if (!username || !input.password) {
82
- throw new AuthInvalidCredentialsError({ message: (optional.messages ?? defaultUserManagementMessages).invalidCredentials });
83
- }
84
- const userRow = await deps.repos.users.findByUsername({ workspaceId: input.workspaceId, username });
85
- if (!userRow) {
86
- throw new AuthInvalidCredentialsError({ message: (optional.messages ?? defaultUserManagementMessages).invalidCredentials });
87
- }
88
- const principal = await deps.repos.principals.findById({
89
- workspaceId: input.workspaceId,
90
- id: userRow.principalId,
91
- });
92
- if (!principal || principal.status !== "active") {
93
- throw new AuthInvalidCredentialsError({ message: (optional.messages ?? defaultUserManagementMessages).invalidCredentials });
94
- }
95
- const passwordOk = await deps.hasher.verify({ hash: userRow.passwordHash, password: input.password });
96
- if (!passwordOk) {
97
- throw new AuthInvalidCredentialsError({ message: (optional.messages ?? defaultUserManagementMessages).invalidCredentials });
98
- }
112
+ const invalidCredentialsMessage = (optional.messages ?? defaultUserManagementMessages).invalidCredentials;
113
+ const { userRow, principal } = await verifyCredentials({ deps, input, invalidCredentialsMessage });
99
114
  const { session, rawToken } = await createSessionForPrincipal({ deps, input: { workspaceId: input.workspaceId, principalId: principal.id } }, { ip: input.ip, userAgent: input.userAgent, sessionTtlMs: optional.sessionTtlMs, messages: optional.messages });
100
115
  // Reuses the session's own `createdAt` rather than a second `kernelNowIso({ clock: clock })` call — both are
101
116
  // meant to be the same instant, and reading one avoids two clock reads ever disagreeing.
@@ -133,7 +148,7 @@ export async function validateSession(required, optional = {}) {
133
148
  id: session.principalId,
134
149
  });
135
150
  // a disabled principal's live session stops validating immediately. See docs/decisions/DR-001-identity-and-session-boundary.md.
136
- if (!principal || principal.status !== "active")
151
+ if (!isActivePrincipal(principal))
137
152
  return null;
138
153
  return { principal, session };
139
154
  }
@@ -29,11 +29,10 @@ export async function resolveEffectivePermissions(required) {
29
29
  * True iff `row` is an exact, unconstrained-or-scope-matching grant of
30
30
  * `permission` in `context` (state.spec §4 `matchesRow`). Assumes `row` has
31
31
  * already passed the owner-wildcard check upstream — this never special-cases
32
- * `"*"`.
32
+ * `"*"`. Callers pass only rows already filtered to `permission` (see
33
+ * `authorize`'s `candidates`), so the permission equality is not re-checked here.
33
34
  */
34
- function matchesRow(row, permission, context) {
35
- if (row.permission !== permission)
36
- return false;
35
+ function matchesRow(row, context) {
37
36
  if (row.constraintJson != null)
38
37
  return false;
39
38
  if (row.resourceType != null && row.resourceType !== context.entityType)
@@ -89,16 +88,15 @@ export async function authorize(required, optional = {}) {
89
88
  if (candidates.length === 0) {
90
89
  return { allowed: false, reason: "no_grant" };
91
90
  }
92
- const matched = candidates.some((row) => matchesRow(row, permission, context));
91
+ const matched = candidates.some((row) => matchesRow(row, context));
93
92
  if (matched) {
94
93
  return { allowed: true, reason: "matched" };
95
94
  }
96
95
  if (candidates.some((row) => row.constraintJson != null)) {
97
96
  return { allowed: false, reason: "unconstrained_deny" };
98
97
  }
99
- if (candidates.some((row) => row.resourceType != null)) {
100
- return { allowed: false, reason: "resource_scope_mismatch" };
101
- }
102
- return { allowed: false, reason: "no_grant" };
98
+ // A candidate fails matchesRow only through a constraint or a mismatched resource scope; with
99
+ // no constraint left, every candidate carries a resource scope that differs from the context.
100
+ return { allowed: false, reason: "resource_scope_mismatch" };
103
101
  }
104
102
  //# sourceMappingURL=authorize.js.map
@@ -18,6 +18,39 @@ export function createPermissionMigrationRegistry(_required = {}, optional = {})
18
18
  register(migration);
19
19
  return { register, list: () => [...byFrom.values()] };
20
20
  }
21
+ /**
22
+ * One policy's share of the fan-out: for each pair whose `from` the policy holds, write every `to`
23
+ * it does not hold yet. `held` is updated as rows are written, so a target named by two pairs is
24
+ * written once; pairs run in list order in a single pass, so a target written here is never then
25
+ * re-read as a later pair's `from` within this same policy. Additive-only — only `save` is called.
26
+ *
27
+ * @returns how many rows were written for this policy.
28
+ */
29
+ async function grantMissingTargets(required) {
30
+ const { deps, policyId } = required;
31
+ const existing = await deps.policyPermissions.listByPolicyId({ workspaceId: deps.workspaceId, policyId });
32
+ const held = new Set(existing.map((row) => row.permission));
33
+ let written = 0;
34
+ for (const migration of deps.migrations) {
35
+ if (!held.has(migration.from))
36
+ continue;
37
+ for (const toPermission of migration.to) {
38
+ if (held.has(toPermission))
39
+ continue;
40
+ await deps.policyPermissions.save({
41
+ id: deps.idGen.newId(),
42
+ workspaceId: deps.workspaceId,
43
+ policyId,
44
+ permission: toPermission,
45
+ resourceType: null,
46
+ constraintJson: null,
47
+ });
48
+ held.add(toPermission);
49
+ written += 1;
50
+ }
51
+ }
52
+ return written;
53
+ }
21
54
  /**
22
55
  * For every `{from, to}` pair in `deps.migrations`, for every policy in the workspace
23
56
  * holding `from`, ensure every string in `to` is also granted — adding only
@@ -36,8 +69,7 @@ export function createPermissionMigrationRegistry(_required = {}, optional = {})
36
69
  * See docs/decisions/DR-003-additive-permission-migration.md.
37
70
  */
38
71
  export async function migrateDeprecatedPermissionGrants(deps) {
39
- const { migrations } = deps;
40
- if (migrations.length === 0)
72
+ if (deps.migrations.length === 0)
41
73
  return { migratedGrantCount: 0 };
42
74
  return inIdentityTransaction({
43
75
  transactions: deps.transactions,
@@ -46,29 +78,7 @@ export async function migrateDeprecatedPermissionGrants(deps) {
46
78
  const policies = await deps.policies.list({ workspaceId: deps.workspaceId });
47
79
  let migratedGrantCount = 0;
48
80
  for (const policy of policies) {
49
- const existing = await deps.policyPermissions.listByPolicyId({
50
- workspaceId: deps.workspaceId,
51
- policyId: policy.id,
52
- });
53
- const held = new Set(existing.map((row) => row.permission));
54
- for (const migration of migrations) {
55
- if (!held.has(migration.from))
56
- continue;
57
- for (const toPermission of migration.to) {
58
- if (held.has(toPermission))
59
- continue;
60
- await deps.policyPermissions.save({
61
- id: deps.idGen.newId(),
62
- workspaceId: deps.workspaceId,
63
- policyId: policy.id,
64
- permission: toPermission,
65
- resourceType: null,
66
- constraintJson: null,
67
- });
68
- held.add(toPermission);
69
- migratedGrantCount += 1;
70
- }
71
- }
81
+ migratedGrantCount += await grantMissingTargets({ deps, policyId: policy.id });
72
82
  }
73
83
  return { migratedGrantCount };
74
84
  },
@@ -12,12 +12,12 @@ export function createTransactionalInMemoryIdentityRepos(required) {
12
12
  throw new Error('identity repositories are already bound to a transaction port');
13
13
  const context = new AsyncLocalStorage();
14
14
  let tail = Promise.resolve();
15
- const stores = Object.entries(required.repos).filter(([key]) => key !== 'transactions');
16
- for (const [, repo] of stores) {
15
+ const stores = Object.entries(required.repos).filter(([key]) => key !== 'transactions').map(([, repo]) => {
17
16
  if (!(repo instanceof InMemoryIdentityRows)) {
18
17
  throw new Error('memory transactions require in-memory identity repositories');
19
18
  }
20
- }
19
+ return repo;
20
+ });
21
21
  /** Preserve queue progress after rejection; descendants of a completed body cannot bypass it. */
22
22
  async function exclusive(execute) {
23
23
  if (context.getStore()?.active)
@@ -37,9 +37,8 @@ export function createTransactionalInMemoryIdentityRepos(required) {
37
37
  }
38
38
  const transactions = {
39
39
  run: ({ execute }) => exclusive(async () => {
40
- const snapshots = stores.map(([, repo]) => {
41
- if (!(repo instanceof InMemoryIdentityRows))
42
- throw new Error('memory transactions require in-memory identity repositories');
40
+ // Every store was checked to be InMemoryIdentityRows when the bag was bound above.
41
+ const snapshots = stores.map((repo) => {
43
42
  const rows = repo.snapshotRows({});
44
43
  return { restore: () => repo.restoreRows({ rows }) };
45
44
  });