@owlmeans/server-auth-identity 0.1.18-rc.41 → 0.1.18-rc.43

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 (60) hide show
  1. package/README.md +77 -42
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-auth-identity/SKILL.md +168 -199
  4. package/build/consts.d.ts +11 -0
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +13 -0
  7. package/build/consts.js.map +1 -1
  8. package/build/events.d.ts.map +1 -1
  9. package/build/events.js +22 -10
  10. package/build/events.js.map +1 -1
  11. package/build/groups.d.ts +23 -0
  12. package/build/groups.d.ts.map +1 -0
  13. package/build/groups.js +50 -0
  14. package/build/groups.js.map +1 -0
  15. package/build/helper.d.ts +6 -1
  16. package/build/helper.d.ts.map +1 -1
  17. package/build/helper.js +6 -2
  18. package/build/helper.js.map +1 -1
  19. package/build/identity.d.ts +49 -0
  20. package/build/identity.d.ts.map +1 -0
  21. package/build/identity.js +205 -0
  22. package/build/identity.js.map +1 -0
  23. package/build/index.d.ts +2 -0
  24. package/build/index.d.ts.map +1 -1
  25. package/build/index.js +2 -0
  26. package/build/index.js.map +1 -1
  27. package/build/native.d.ts +14 -0
  28. package/build/native.d.ts.map +1 -0
  29. package/build/native.js +12 -0
  30. package/build/native.js.map +1 -0
  31. package/build/resolver.d.ts.map +1 -1
  32. package/build/resolver.js +52 -30
  33. package/build/resolver.js.map +1 -1
  34. package/build/resource.d.ts +3 -0
  35. package/build/resource.d.ts.map +1 -1
  36. package/build/resource.js +19 -14
  37. package/build/resource.js.map +1 -1
  38. package/build/service.d.ts +6 -2
  39. package/build/service.d.ts.map +1 -1
  40. package/build/service.js +80 -211
  41. package/build/service.js.map +1 -1
  42. package/build/types.d.ts +154 -24
  43. package/build/types.d.ts.map +1 -1
  44. package/package.json +7 -6
  45. package/src/consts.ts +16 -0
  46. package/src/events.ts +29 -10
  47. package/src/groups.ts +67 -0
  48. package/src/helper.ts +8 -2
  49. package/src/identity.ts +240 -0
  50. package/src/index.ts +2 -0
  51. package/src/native.ts +18 -0
  52. package/src/resolver.ts +65 -36
  53. package/src/resource.ts +19 -14
  54. package/src/service.ts +86 -224
  55. package/src/types.ts +163 -24
  56. package/tests/context.ts +188 -79
  57. package/tests/events.spec.ts +58 -55
  58. package/tests/identity.spec.ts +148 -0
  59. package/tests/linking.spec.ts +120 -90
  60. package/tests/resolver.spec.ts +112 -0
package/README.md CHANGED
@@ -10,7 +10,7 @@ whose grants decide access (use `@owlmeans/server-oidc-rp`'s guard and gate, or
10
10
  ## Installation
11
11
 
12
12
  ```bash
13
- bun add @owlmeans/server-auth-identity@^0.1.18-rc.41
13
+ bun add @owlmeans/server-auth-identity@^0.1.18-rc.43
14
14
  ```
15
15
 
16
16
  ## Concepts
@@ -19,17 +19,23 @@ bun add @owlmeans/server-auth-identity@^0.1.18-rc.41
19
19
  never on the wire), `slug` (renameable `entitySlug`, the only value a token carries),
20
20
  `formerSlugs` that keep resolving after a rename, a frozen `iamKey`, and `names` minted once for
21
21
  systems that cannot be renamed.
22
- - **Account (`IdentityAccount`)** — one per person, matched on a verified email `name`.
23
- - **Profile (`IdentityProfile`)** — one person inside one organization entity, with `role`,
24
- `scopes` and optional `expiresAt`. The durable authorization record.
25
- - **Credentials (`IdentityCredentials`)** — one provider link per profile, keyed by
26
- `"{type}:{service}:{providerSub}"`.
27
- - **Linking service** — `IdentityLinkingService` finds or creates the local identity for provider
28
- profile details and returns an `AuthPayload`.
22
+ - **Account (`IdentityAccount`)** — one per person: a unique, lower-case `email`, a display `name`,
23
+ and `entityId`, the personal organization created with it.
24
+ - **Credentials (`IdentityCredentials`)** — one row per sign-in method of an account (`accountId`),
25
+ keyed by `"{type}:{service}:{providerSub}"`.
26
+ - **Profile row (`IdentityProfile`)** — one person in one organization for one app (`service`):
27
+ `profileId` (computed by `profileIdOf(service, accountId)`), `owner`, `role`, `scopes`,
28
+ `permissions`, `groups`. The row in the account's own organization is the app's PRIMARY row.
29
+ - **Groups (`OrgGroup`)** — an app's groups, kept in the organization record, unique per
30
+ (`service`, `key`).
31
+ - **Primitives** — `ensureAccount` (find-or-register an account by method, then address) and
32
+ `ensureProfile` (find-or-create a row, primary row first).
33
+ - **Linking service** — `IdentityLinkingService` signs a provider login into the deployment's own
34
+ app and returns an `AuthPayload`.
29
35
  - **Entity resolver** — the `EntityResolverService` registered under `ENTITY_RESOLVER`. Registering
30
36
  it tells the server boundary that this deployment has organizations.
31
- - **Identity events** — `identityEvents(ctx)` returns the `IdentityEventsService`; its
32
- `onEntityCreated` listeners run once per newly registered organization entity.
37
+ - **Identity events** — `identityEvents(ctx)` returns the `IdentityEventsService`:
38
+ `onProfileCreated` runs once per (account, app), `onEntityCreated` once per registration.
33
39
 
34
40
  ## Usage
35
41
 
@@ -62,7 +68,8 @@ import { appendAuthIdentityResources, AUTH_IDENTITY_DB_ALIAS } from '@owlmeans/s
62
68
 
63
69
  appendMongo<C, T>(context)
64
70
  appendAuthService<C, T>(context)
65
- appendAuthIdentityResources(context, AUTH_IDENTITY_DB_ALIAS)
71
+ // MY_APP: this deployment's own app key — the `service` of the rows its sign-in writes
72
+ appendAuthIdentityResources(context, AUTH_IDENTITY_DB_ALIAS, { service: MY_APP })
66
73
  context.registerService(makeMyAppGate())
67
74
  ```
68
75
 
@@ -89,7 +96,9 @@ export const makeMyAppGate = (alias: string = MY_APP_GATE): GateService => {
89
96
 
90
97
  const profile = await ctx.resource<IdentityProfileResource>(AUTH_IDENTITY_PROFILE)
91
98
  .load({ entityId, profileId: req.auth.profileId })
92
- if (profile == null || (profile.expiresAt != null && new Date(profile.expiresAt) < new Date())) {
99
+ // The store also holds other apps' rows of the same organization — only this app's count.
100
+ if (profile == null || profile.service !== MY_APP || profile.disabled === true
101
+ || (profile.expiresAt != null && new Date(profile.expiresAt) < new Date())) {
93
102
  throw new AuthForbidden('profile')
94
103
  }
95
104
 
@@ -119,7 +128,7 @@ const api = handlers<Context>()
119
128
 
120
129
  export const members = api.request(memberProtocols.list, async (request, context) => {
121
130
  const { items } = await context.resource<IdentityProfileResource>(AUTH_IDENTITY_PROFILE)
122
- .list({ entityId: requireEntityKey(request) }, { sort: [{ field: 'createdAt', order: 'desc' }] })
131
+ .list({ entityId: requireEntityKey(request), service: MY_APP }, { size: 0, sort: [{ field: 'createdAt', order: 'desc' }] })
123
132
 
124
133
  return items.map(({ profileId, role, name }) => ({ profileId, role, name }))
125
134
  })
@@ -140,29 +149,42 @@ const details = {
140
149
  userId: providerSub, // the provider's subject claim
141
150
  }
142
151
 
143
- // Returning login: find the credential, then its profile.
152
+ // Returning login: the credential, its account, this app's row in the account's organization.
144
153
  let payload = await linking.getLinkedProfile(details)
145
- // First login by this method: link it to the person's identity, registering one if new.
154
+ // Otherwise: the account by the verified address (registered if new), the method attached to it,
155
+ // and this app's owner row in the person's own organization.
146
156
  payload ??= await linking.linkProfile(details, { username: 'person@example.org' })
147
- // payload: { type, role, userId, profileId, entitySlug, scopes }
157
+ // payload: { type, role, userId: accountId, profileId, entitySlug, scopes }
148
158
  ```
149
159
 
150
- ### Provision when an organization is created
160
+ ### Rows for a hosted app
161
+
162
+ ```ts
163
+ import { ensureAccount, ensureProfile } from '@owlmeans/server-auth-identity'
164
+
165
+ const { account } = await ensureAccount(context, { email }, details) // writes no row
166
+ const row = await ensureProfile(context, { account, service: clientId, entityId: account.entityId, owner: true })
167
+ // a hosted app's rows carry `scopes: []`; a row in another organization brings the primary row with it
168
+ ```
169
+
170
+ ### Provision when a person becomes a user of the app
151
171
 
152
172
  ```ts
153
173
  import { identityEvents } from '@owlmeans/server-auth-identity'
154
174
 
155
175
  // in makeContext, after appendAuthIdentityResources
156
- identityEvents(context)?.onEntityCreated(async (event, ctx) => {
176
+ identityEvents(context)?.onProfileCreated(async (event, ctx) => {
177
+ if (event.service !== MY_APP) return
157
178
  await ctx.service<PlanService>(PLAN_SERVICE).grantStarterPlan(event.entityId)
158
179
  })
159
180
  ```
160
181
 
161
- The event carries `entityId`, `entitySlug`, `iamKey`, `accountId`, `profileId`, `username`, the
162
- login `type` and `service`, and `createdAt`. It fires only when `linkProfile` registers a new
163
- identity (including `force: true`) — never when a second sign-in method links to an identity that
164
- exists. Listeners run in order and are awaited; one that throws is logged and never fails the
165
- sign-in, so anything a listener provisions needs its own backfill.
182
+ `onProfileCreated` carries `entityId`, `entitySlug`, `accountId`, `profileId`, `service` and
183
+ `owner`, and fires once per (account, app), when its primary row is created. `onEntityCreated`
184
+ carries `entityId`, `entitySlug`, `iamKey`, `accountId`, `profileId`, `username`, the login `type`
185
+ and `service`, the owner row's app as `profileService`, and `createdAt`; `linkProfile` fires it when
186
+ the sign-in registered the person. Listeners run in order and are awaited; one that throws is logged
187
+ and never fails the sign-in, so anything a listener provisions needs its own backfill.
166
188
 
167
189
  ### Rename an organization and mint a durable name
168
190
 
@@ -187,12 +209,17 @@ const namespace = await resolver.mintName(entityId, 'namespace', entity => `my-a
187
209
 
188
210
  | Symbol | Kind | Purpose |
189
211
  |---|---|---|
190
- | `appendAuthIdentityResources(context, dbAlias?)` | function | Register the four resources, the linking service, the entity resolver and (unless one is registered) the identity-events service |
212
+ | `appendAuthIdentityResources(context, dbAlias?, { service? })` | function | Register the four resources, the linking service for app `service`, the entity resolver and (unless one is registered) the identity-events service |
213
+ | `profileIdOf(service, accountId)` | function | The computed profile id of an (account, app) |
214
+ | `ensureAccount(ctx, { email, name? }, details?)` | function | The person's account — by method, then address, else registered with a personal organization |
215
+ | `ensureProfile(ctx, { account, service, entityId, … })` | function | Find-or-create a row; the primary row first |
216
+ | `credentialKeyOf(details)`, `credentialOf(ctx, details)`, `normalizeEmail(email)` | function | A method's credential key and stored row; the stored address form |
217
+ | `listOrgGroups`, `putOrgGroup`, `removeOrgGroup` | function | An organization's groups of one app, by guarded field-level writes |
191
218
  | `makeOrgEntityResource(dbAlias?)` | function | Mongo resource for `OrgEntity` |
192
219
  | `makeIdentityAccountResource(dbAlias?)` | function | Mongo resource for `IdentityAccount` |
193
220
  | `makeIdentityProfileResource(dbAlias?)` | function | Mongo resource for `IdentityProfile` |
194
221
  | `makeIdentityCredentialsResource(dbAlias?)` | function | Mongo resource for `IdentityCredentials` |
195
- | `makeIdentityLinkingService()` | function | The `IdentityLinkingService` implementation |
222
+ | `makeIdentityLinkingService({ service? })` | function | The `IdentityLinkingService` implementation for one app |
196
223
  | `makeEntityResolverService(alias = ENTITY_RESOLVER)` | function | The `EntityResolverService` implementation, cached 30 s per resolved name |
197
224
  | `makeIdentityEventsService(alias = AUTH_IDENTITY_EVENTS)` | function | The `IdentityEventsService` implementation (lazy) |
198
225
  | `identityEvents(ctx, alias?)` | function | The registered events service, or `null` |
@@ -206,21 +233,25 @@ const namespace = await resolver.mintName(entityId, 'namespace', entity => `my-a
206
233
  | `AUTH_IDENTITY_EVENTS` | const | `'auth-identity:events'` — identity-events service alias |
207
234
  | `AUTH_IDENTITY_DB_ALIAS` | const | `'auth-identity'` — suggested db config alias |
208
235
  | `AUTH_IDENTITY_ORG_ENTITY_COLLECTION`, `AUTH_IDENTITY_ACCOUNT_COLLECTION`, `AUTH_IDENTITY_PROFILE_COLLECTION`, `AUTH_IDENTITY_CREDENTIALS_COLLECTION` | const | Colon-free Mongo collection base names |
236
+ | `DEFAULT_APP_SERVICE` | const | `'app'` — the own app key when none is passed |
209
237
  | `MAX_ENTITY_SLUG_ATTEMPTS` | const | `8` — word slugs tried before minting gives up |
238
+ | `MAX_ACCOUNT_KEY_ATTEMPTS`, `MAX_GUARDED_UPDATE_ATTEMPTS`, `PROFILE_DIGEST_LENGTH` | const | Retry bounds; the `profileId` digest length |
210
239
  | `LOGIN_SERVICE_PREFIX`, `EXTERNAL_KEY_DELIMITER` | const | `'service'` and `':'` — the derived-key grammar |
211
240
 
212
241
  ### Types
213
242
 
214
243
  | Symbol | Kind | Purpose |
215
244
  |---|---|---|
216
- | `OrgEntity` | type | `id`, `slug`, `formerSlugs?`, `iamKey`, `names?`, `createdAt`, `updatedAt?` |
217
- | `IdentityAccount` | type | `Profile` without `entitySlug`, plus `id`, `credential`, `entityId?` |
218
- | `IdentityProfile` | type | `Profile` without `entitySlug`, plus `id`, `profileId`, `userId?`, `role`, `entityId?`, `expiresAt?` |
219
- | `IdentityCredentials` | type | `AuthCredentials` + `profileId` |
245
+ | `OrgEntity` | type | `id`, `slug`, `formerSlugs?`, `iamKey`, `title?`, `names?`, `groups?`, `createdAt`, `updatedAt?` |
246
+ | `OrgGroup`, `OrgGroupBundle`, `OrgGroupFilter` | type | An app's group in an organization and its permission bundles |
247
+ | `IdentityAccount` | type | `id`, `credential`, `email`, `name`, `entityId`, `scopes` |
248
+ | `IdentityProfile` | type | `id`, `profileId`, `userId`, `service`, `entityId`, `owner?`, `role`, `scopes`, `permissions`, `groups?`, `home?`, `disabled?`, `managed?` |
249
+ | `IdentityCredentials` | type | `AuthCredentials` + `accountId` (`profileId?` reserved) |
250
+ | `EnsureAccountArgs`, `EnsuredAccount`, `EnsureProfileArgs`, `IdentityResourcesOptions` | type | The primitives' arguments and results |
220
251
  | `IdentityLinkingService` | type | `getLinkedProfile`, `linkProfile`, `linkCredentials`, `unlinkCredentials`, `getOwnerProfiles`, `getOwnerCredentials` |
221
- | `AccountMeta` | type | `{ username, force? }` |
222
- | `IdentityEventsService` | type | `onEntityCreated(callback)`, `propagateEntityCreated(event)` |
223
- | `EntityCreatedEvent`, `EntityCreatedCallback` | type | The entity-created payload; `(event, ctx) => Promise<void>` |
252
+ | `AccountMeta` | type | `{ username }` — the verified address |
253
+ | `IdentityEventsService` | type | `onEntityCreated` / `propagateEntityCreated`, `onProfileCreated` / `propagateProfileCreated` |
254
+ | `EntityCreatedEvent`, `ProfileCreatedEvent` and their callbacks | type | The event payloads; `(event, ctx) => Promise<void>` |
224
255
  | `OrgEntityResource`, `IdentityAccountResource`, `IdentityProfileResource`, `IdentityCredentialsResource` | type | Typed `MongoResource` aliases |
225
256
  | `IdentityConfig`, `IdentityContext` | type | Server config and context shapes |
226
257
  | `GoogleUserInfo` | type | Google userinfo claims |
@@ -228,12 +259,14 @@ const namespace = await resolver.mintName(entityId, 'namespace', entity => `my-a
228
259
 
229
260
  ## Key derivation
230
261
 
231
- - Account `credential` — a unique 16-character Base58 slug.
232
- - Account / profile `entityId` — the `OrgEntity` id; a first registration creates the entity first.
233
- - Profile `userId` — the account's Mongo id, a declared ObjectId reference.
234
- - Profile `profileId` — `"{type}:{accountId}"`.
262
+ - Account `credential` — a unique 16-character Base58 key.
263
+ - Account `email` — trimmed, lower-case, unique.
264
+ - Account / profile `entityId`, profile `userId` and `home`, credentials `accountId` — record ids,
265
+ declared ObjectId references; a first registration creates the organization first.
266
+ - Profile `profileId` — `profileIdOf(service, accountId)`: `"{service}:"` + 22 Base58 characters of
267
+ `sha256("{accountId}:{service}")`. Computed, the same on every row of one (account, app).
235
268
  - Credentials `userId` — `"{type}:{service}:{providerSub}"`; unrelated to the profile's `userId`.
236
- - Credentials / profile `credential` — `"service:{type}:{service}"`; only platform logins carry it.
269
+ - Credentials `credential` — `"service:{type}:{service}"`.
237
270
 
238
271
  ## Common pitfalls
239
272
 
@@ -244,10 +277,12 @@ const namespace = await resolver.mintName(entityId, 'namespace', entity => `my-a
244
277
  - Without the resolver `request.entity` stays undefined and consumers fall back to the slug — call
245
278
  `appendAuthIdentityResources` (or register the resolver) in every service that serves organization
246
279
  data.
247
- - First-login profiles get `ALL_SCOPES` and `AuthRole.User`; narrow scopes where finer authorization
248
- is needed.
249
- - `linkProfile(details, { username, force: true })` always registers a new identity — use it only
250
- when a separate identity is intended.
280
+ - `linkProfile` rows get `ALL_SCOPES` and `AuthRole.User`; a hosted app's rows (`ensureProfile`
281
+ default) get `[]`. A gate of the own app must also require `profile.service`.
282
+ - The address is the identity: whoever names an address to `ensureAccount` / `linkProfile` gets that
283
+ person's account, so only a verified address may reach them.
284
+ - Never `update()` an organization record — write fields (the resolver, the group helpers, a guarded
285
+ `collection.updateOne`), or a stale read erases groups and minted names.
251
286
  - Provisioning in an `onEntityCreated` listener is best-effort: a throwing listener is logged and the
252
287
  sign-in succeeds, so reconcile what it provisions periodically.
253
288
  - The resolver caches for 30 seconds, so a rename reaches other replicas within that window; the old
@@ -273,7 +308,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
273
308
  your project's skill store (`.agents/skills/`):
274
309
 
275
310
  ```sh
276
- npx @owlmeans/agent-skills@^0.1.18-rc.42
311
+ npx @owlmeans/agent-skills@^0.1.18-rc.45
277
312
  ```
278
313
 
279
314
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/server-auth-identity",
4
- "version": "0.1.18-rc.41",
5
- "generatedAt": "2026-09-29T08:01:58.112Z",
4
+ "version": "0.1.18-rc.43",
5
+ "generatedAt": "2026-10-01T23:27:48.721Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {