@owlmeans/server-auth-identity 0.1.18-rc.2 → 0.1.18-rc.21

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/README.md CHANGED
@@ -7,12 +7,12 @@ Mongo-backed local identity resources and provider account linking for OwlMeans
7
7
  - Registers three Mongo resources: local accounts, local profiles, and provider credentials.
8
8
  - Provides `IdentityLinkingService` to map external provider profile details into an OwlMeans `AuthPayload`.
9
9
  - Keeps external provider identity separate from local product identity and authorization scopes.
10
- - Designed to work with `@owlmeans/server-auth`, `@owlmeans/server-oidc-rp`, and product-specific module gates.
10
+ - Designed to work with `@owlmeans/server-auth`, `@owlmeans/server-oidc-rp`, and product-specific entrypoint gates.
11
11
 
12
12
  ## Installation
13
13
 
14
14
  ```bash
15
- bun add @owlmeans/server-auth-identity
15
+ bun add @owlmeans/server-auth-identity@^0.1.18-rc.12
16
16
  ```
17
17
 
18
18
  ## Usage
@@ -43,9 +43,10 @@ import { AUTH_IDENTITY_PROFILE } from '@owlmeans/server-auth-identity'
43
43
  import type { IdentityProfileResource } from '@owlmeans/server-auth-identity'
44
44
 
45
45
  const profiles = context.resource<IdentityProfileResource>(AUTH_IDENTITY_PROFILE)
46
- const profile = await profiles.load(profileId, 'profileId')
46
+ const profile = await profiles.load({ profileId })
47
+ const scoped = await profiles.load({ entityId, profileId })
47
48
 
48
- const { items } = await profiles.list({ entityId, profileId } as any)
49
+ const { items, total } = await profiles.list({ entityId })
49
50
  ```
50
51
 
51
52
  ## API
@@ -83,7 +84,7 @@ const { items } = await profiles.list({ entityId, profileId } as any)
83
84
  - Google/OIDC login is only the provider bootstrap path; durable authorization data lives in `IdentityProfile`.
84
85
  - Backend contexts should register `appendAuthService(context)`, then `appendAuthIdentityResources(context)`, then product gate services.
85
86
  - Product gates should read `AUTH_IDENTITY_PROFILE`, verify `entityId`, reject expired or blocked profiles, and compare gate params against profile scopes.
86
- - `Resource.pick()` is destructive and deletes the matching record. Never use `pick()` for gate or handler reads; use `load()` or `list()`.
87
+ - `Resource.take()` is destructive and deletes the record it returns. Never use `take()` for gate or handler reads; use `load(where)` or `list(where)`.
87
88
 
88
89
  ## Related Packages
89
90
 
@@ -101,7 +102,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
101
102
  your project's skill store (`.agents/skills/`):
102
103
 
103
104
  ```sh
104
- npx @owlmeans/agent-skills
105
+ npx @owlmeans/agent-skills@^0.1.18-rc.20
105
106
  ```
106
107
 
107
108
  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.0",
5
- "generatedAt": "2026-08-16T22:20:50.498Z",
4
+ "version": "0.1.18-rc.21",
5
+ "generatedAt": "2026-09-12T14:18:54.780Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: server-auth-identity
3
- description: How to use @owlmeans/server-auth-identity — Mongo-backed local identity resources (account, profile, credentials) and IdentityLinkingService for provider account linking. Auto-invoked when importing appendAuthIdentityResources, identity resource aliases, or IdentityLinkingService.
3
+ description: How to use @owlmeans/server-auth-identity — the Mongo-backed local identity store behind provider logins. Registers the organization-entity registry plus account, profile and credentials resources, the IdentityLinkingService that maps an external provider account onto one local identity, and the EntityResolverService that turns the slug on a token into a stable entity id. Auto-invoked when importing appendAuthIdentityResources, an identity resource alias, IdentityLinkingService or the entity resolver.
4
4
  user-invocable: false
5
5
  ---
6
6
  <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
@@ -8,61 +8,108 @@ user-invocable: false
8
8
  # @owlmeans/server-auth-identity
9
9
 
10
10
  **Layer:** Server
11
- **Install:** `"@owlmeans/server-auth-identity": "^0.1.18-rc.0"` in `dependencies`
11
+ **Install:** `"@owlmeans/server-auth-identity": "^0.1.18-rc.21"` in `dependencies`
12
+
13
+ The local identity store a deployment owns when it does not delegate identity to an external IAM.
14
+ It answers two questions: *who is this person here* (account / profile / credentials) and *which
15
+ organization entity is this* (the org-entity registry and its resolver).
12
16
 
13
17
  ## Key Exports
14
18
 
15
19
  | Export | Description |
16
20
  |--------|-------------|
17
- | `appendAuthIdentityResources(context, dbAlias?)` | Register all three identity resources + linking service on a context |
18
- | `makeIdentityAccountResource(dbAlias?)` | Mongo resource for `IdentityAccount` (one per user) |
19
- | `makeIdentityProfileResource(dbAlias?)` | Mongo resource for `IdentityProfile` (ties user to entity + role) |
20
- | `makeIdentityCredentialsResource(dbAlias?)` | Mongo resource for `IdentityCredentials` (provider link per profile) |
21
- | `makeIdentityLinkingService()` | Service implementing `IdentityLinkingService` — links external providers to local profiles |
21
+ | `appendAuthIdentityResources(context, dbAlias?)` | Register the four resources, the linking service and the entity resolver in one call |
22
+ | `makeOrgEntityResource(dbAlias?)` | Mongo resource for `OrgEntity` — the organization-entity registry |
23
+ | `makeIdentityAccountResource(dbAlias?)` | Mongo resource for `IdentityAccount` (one per person) |
24
+ | `makeIdentityProfileResource(dbAlias?)` | Mongo resource for `IdentityProfile` (one person inside one organization entity, with a role) |
25
+ | `makeIdentityCredentialsResource(dbAlias?)` | Mongo resource for `IdentityCredentials` (one provider link per profile) |
26
+ | `makeIdentityLinkingService()` | The `IdentityLinkingService` implementation |
27
+ | `makeEntityResolverService(alias?)` | The `EntityResolverService` implementation, cached for 30 s per resolved value |
28
+ | `AUTH_IDENTITY_ORG_ENTITY` | Resource alias `'auth-identity:org-entity'` |
22
29
  | `AUTH_IDENTITY_ACCOUNT` | Resource alias `'auth-identity:account'` |
23
30
  | `AUTH_IDENTITY_PROFILE` | Resource alias `'auth-identity:profile'` |
24
31
  | `AUTH_IDENTITY_CREDENTIALS` | Resource alias `'auth-identity:credentials'` |
25
32
  | `AUTH_IDENTITY_LINKING` | Service alias `'auth-identity:linking'` |
33
+ | `AUTH_IDENTITY_DB_ALIAS` | `'auth-identity'` — the suggested db-config alias whose `resourcePrefix` scopes the prefix to identity collections only |
34
+ | `AUTH_IDENTITY_ORG_ENTITY_COLLECTION`, `AUTH_IDENTITY_ACCOUNT_COLLECTION`, `AUTH_IDENTITY_PROFILE_COLLECTION`, `AUTH_IDENTITY_CREDENTIALS_COLLECTION` | Colon-free Mongo collection base names (`org-entity`, `account`, `profile`, `credentials`) |
35
+ | `MAX_ENTITY_SLUG_ATTEMPTS` | How many word slugs to try before giving up minting a free one |
36
+ | `LOGIN_SERVICE_PREFIX`, `EXTERNAL_KEY_DELIMITER` | `'service'` and `':'` — the grammar of every derived key below |
37
+
38
+ The resource **aliases** keep their colon-delimited form and are lookup keys only; the Mongo
39
+ collection names are the colon-free `*_COLLECTION` values, optionally prefixed by the db config's
40
+ `resourcePrefix`.
26
41
 
27
42
  ## Types
28
43
 
29
44
  | Type | Description |
30
45
  |------|-------------|
31
- | `IdentityAccount` | `Profile & ResourceRecord` — `id`, `credential` (unique entity slug) |
32
- | `IdentityProfile` | `Profile & ResourceRecord` — `id`, `profileId`, `userId?`, `role`, `entityId`, `scopes`, `expiresAt?` |
33
- | `IdentityCredentials` | `AuthCredentials & ResourceRecord` — `profileId`, `type`, `userId` (external key), `credential` (login-service key) |
46
+ | `OrgEntity` | `id`, `slug`, `formerSlugs?`, `iamKey`, `names?`, `createdAt`, `updatedAt?` |
47
+ | `IdentityAccount` | `Profile` without `entitySlug`, plus `id`, `credential`, `entityId?` |
48
+ | `IdentityProfile` | `Profile` without `entitySlug`, plus `id`, `profileId`, `userId?`, `role`, `entityId?`, `expiresAt?` |
49
+ | `IdentityCredentials` | `AuthCredentials` + `profileId` |
34
50
  | `IdentityLinkingService` | `getLinkedProfile`, `linkProfile`, `linkCredentials`, `getOwnerProfiles`, `getOwnerCredentials` |
51
+ | `AccountMeta` | `{ username, force? }` — the second argument to `linkProfile` |
52
+ | `OrgEntityResource`, `IdentityAccountResource`, `IdentityProfileResource`, `IdentityCredentialsResource` | Typed `MongoResource` aliases |
53
+ | `IdentityConfig`, `IdentityContext` | Server config/context shapes |
54
+ | `GoogleUserInfo` | The Google userinfo claim set |
55
+ | `EmailIdentityArgs`, `EmailIdentityPayload`, `IdentityIamExtension` | Declaration-only seam for an IAM-backed email find-or-create |
56
+
57
+ The three `*Identity*` seam types are declarations and nothing more: no function in this
58
+ package accepts or returns them, and no consumer imports them. An email login is wired through
59
+ `IdentityLinkingService.getLinkedProfile` / `linkProfile`, which is what the OTP plugin does.
60
+
61
+ ## The organization entity
62
+
63
+ The customer organization is an **organization entity**, and this package is the registry that owns
64
+ it. Two values name one organization and they never substitute for each other:
65
+
66
+ - **`entityId`** — `OrgEntity.id`. Stable, never on the wire. Every account, profile and
67
+ organization-scoped record keys on it.
68
+ - **`entitySlug`** — `OrgEntity.slug`. Renameable, human-readable, and the only organization value
69
+ an auth payload carries. Every `AuthPayload` this service returns carries `entitySlug`, resolved
70
+ from the stored `entityId`; an id that no longer resolves yields `undefined` rather than leaking a
71
+ raw id where a consumer would read it as a slug.
72
+
73
+ A rename is one write to `OrgEntity`, because nothing else stores the slug. The retired name moves
74
+ into `formerSlugs` and keeps resolving, so tokens minted before the rename and third-party records
75
+ still quoting the old value keep working.
76
+
77
+ `iamKey` is frozen at creation and never recomputed. It is what systems that cannot be renamed —
78
+ an IAM realm, a namespace, a storage prefix — are told once; `mintName(id, key, mint)` is the
79
+ read-before-mint that freezes such a derived name into `OrgEntity.names`.
80
+
81
+ Registering the resolver is also the signal that this deployment **has** organizations: without it
82
+ `request.entity` stays undefined and every consumer falls back to treating the token's slug as the
83
+ only entity value there is.
35
84
 
36
85
  ## Usage
37
86
 
38
- ### Register in context
87
+ ### Register in a context
39
88
 
40
89
  ```typescript
41
90
  import { appendAuthIdentityResources } from '@owlmeans/server-auth-identity'
42
91
 
43
- // In your makeContext factory, after appendMongo/appendRedis/appendAuthService:
92
+ // in makeContext, after the Mongo/Redis services and appendAuthService:
44
93
  appendAuthIdentityResources(context)
45
94
  ```
46
95
 
47
- This registers three Mongo resources and the linking service in one call.
48
-
49
- ### Read a profile (gate / handler)
96
+ ### Read a profile (gate or handler)
50
97
 
51
98
  ```typescript
52
99
  import { AUTH_IDENTITY_PROFILE } from '@owlmeans/server-auth-identity'
53
100
  import type { IdentityProfileResource } from '@owlmeans/server-auth-identity'
101
+ import { requireEntityKey } from '@owlmeans/auth-common'
54
102
 
55
- const profileRes = ctx.resource<IdentityProfileResource>(AUTH_IDENTITY_PROFILE)
56
-
57
- // Single-field indexed lookup (read-only):
58
- const profile = await profileRes.load(profileId, 'profileId')
103
+ const profiles = ctx.resource<IdentityProfileResource>(AUTH_IDENTITY_PROFILE)
104
+ const entityId = requireEntityKey(req)
59
105
 
60
- // Multi-field criteria lookup (read-only):
61
- const { items } = await profileRes.list({ entityId, profileId } as any)
62
- const profile = items[0] ?? null
106
+ const profile = await profiles.load({ entityId, profileId })
107
+ const { items } = await profiles.list({ entityId }, { sort: [{ field: 'createdAt', order: 'desc' }] })
63
108
  ```
64
109
 
65
- ### Link an external provider to a local profile
110
+ Query profiles by `entityId`, never by the slug — `requireEntityKey` hands you the resolved id.
111
+
112
+ ### Link a provider account to a local identity
66
113
 
67
114
  ```typescript
68
115
  import { AUTH_IDENTITY_LINKING } from '@owlmeans/server-auth-identity'
@@ -70,47 +117,75 @@ import type { IdentityLinkingService } from '@owlmeans/server-auth-identity'
70
117
 
71
118
  const linking = ctx.service<IdentityLinkingService>(AUTH_IDENTITY_LINKING)
72
119
 
73
- // First login — creates account + profile + credentials:
74
- const payload = await linking.linkProfile(providerDetails, { username: 'display name' })
75
-
76
- // Subsequent logins — finds existing profile by external key:
77
- const payload = await linking.getLinkedProfile(providerDetails)
78
- ```
79
-
80
- Both return `AuthPayload` with `{ type, role, userId, profileId, entityId, scopes }`.
81
-
82
- ### ProviderProfileDetails shape (input)
83
-
84
- ```typescript
85
- interface ProviderProfileDetails {
86
- type: string // e.g. 'google'
87
- service: string // e.g. 'google' (provider service alias)
88
- userId: string // provider subject (sub claim)
89
- profileId?: string // optional existing profile to link to
120
+ const details = {
121
+ type: 'google-oauth', // the AuthenticationType
122
+ service: 'google', // the provider service alias
123
+ clientId: 'google', // the provider client this login came through
124
+ userId: providerSub, // the provider's subject claim
90
125
  }
91
- ```
92
-
93
- ## Key Derivation Conventions
94
-
95
- - **Profile userId**: the account's mongo id — a **declared ObjectId reference**
96
- (`resource.reference('userId', AUTH_IDENTITY_ACCOUNT)`): stored as `ObjectId`, exchanged
97
- as a string, auto-indexed and auto-migrated at boot. The only reference in the trio.
98
- - **Account credential**: unique Base58 slug (16 chars) — serves as the local `entityId`
99
- - **Profile profileId**: `"{type}:{accountId}"` — stable across provider re-links; a composite key, NOT a reference
100
- - **Credentials userId** (external key): `"{type}:{service}:{providerSub}"` — uniquely identifies the external account. Same name as `profile.userId`, entirely different meaning — never convert or compare across the two
101
- - **Credentials credential** (login-service key): `"service:{type}:{service}"` — groups credentials by provider
102
126
 
103
- `AuthPayload.userId` is emitted as `profile.userId ?? profile.profileId`, so a value flowing
104
- back into a `userId` query may be a composite key — criteria tolerate it (matches nothing)
105
- by the reference conversion's design.
127
+ // Returning login: find the credential, then its profile.
128
+ let payload = await linking.getLinkedProfile(details)
129
+ // First login by this method: link it to the person's identity, registering one if new.
130
+ payload ??= await linking.linkProfile(details, { username: 'person@example.org' })
131
+ ```
106
132
 
107
- ## Resource Indexes
133
+ Both return an `AuthPayload` — `{ type, role, userId, profileId, entitySlug, scopes }`.
134
+
135
+ `linkCredentials(details)` adds a credential row to an already-linked profile named by
136
+ `details.profileId`. `getOwnerProfiles(entityId)` lists an organization's profiles;
137
+ `getOwnerCredentials(userId, entityId?, type?)` returns one profile's credential as an
138
+ `AuthCredentials`.
139
+
140
+ ## One person, one platform identity
141
+
142
+ `linkProfile` **links** a person the platform already knows and registers only a genuinely new one.
143
+ It matches on the account's `name`, which every shipped provider establishes as a verified email
144
+ before it gets here (Google asserts the address, OTP proves possession of it, the supervisor key is
145
+ a full-trust developer credential). A second sign-in method therefore adds a second **credential**
146
+ to one profile — never a second account, profile or organization entity.
147
+
148
+ Two rows can legitimately carry one address, and only one of them is a platform login: an
149
+ organization's invited **end user** is a person-shaped row for the generated application to
150
+ authenticate, deliberately separate from the platform credential. They are told apart by the
151
+ profile's `credential`: this service is the only writer of `"service:{type}:{service}"`, so a
152
+ profile carrying one is a platform login and a profile carrying none is somebody's end user.
153
+
154
+ `linkProfile(details, { username, force: true })` skips the lookup and registers a fresh identity
155
+ regardless. Use it only where a genuinely separate identity is intended.
156
+
157
+ ## Key derivation conventions
158
+
159
+ - **Account `credential`** — a unique 16-character Base58 slug, the account's own stable key.
160
+ Generation retries up to five times on a duplicate-key collision; that is the designed behaviour
161
+ for the slug space, not an error path.
162
+ - **Account / profile `entityId`** — the `OrgEntity` record id. Every first registration creates the
163
+ entity record **first**, because it owns the id everything else is keyed by and the frozen
164
+ `iamKey`.
165
+ - **Profile `userId`** — the account's Mongo id, and a **declared ObjectId reference**
166
+ (`resource.reference('userId', AUTH_IDENTITY_ACCOUNT)`): stored as `ObjectId`, exchanged as a
167
+ string, auto-indexed and auto-migrated at boot. The only reference in the set.
168
+ - **Profile `profileId`** — `"{type}:{accountId}"`. A composite key, stable across provider
169
+ re-links, and NOT a reference.
170
+ - **Credentials `userId`** (external key) — `"{type}:{service}:{providerSub}"`. Same field name as
171
+ `profile.userId`, entirely different meaning; never compare or convert across the two.
172
+ - **Credentials / profile `credential`** (login-service key) — `"service:{type}:{service}"`.
173
+
174
+ `AuthPayload.userId` is emitted as `profile.userId ?? profile.profileId`, so a value coming back
175
+ into a `userId` query may be a composite key. The reference conversion tolerates it by matching
176
+ nothing.
177
+
178
+ ## Resource indexes
108
179
 
109
180
  | Resource | Index | Fields | Notes |
110
181
  |----------|-------|--------|-------|
182
+ | OrgEntity | `slug` | `{ slug: 1 }` | unique — the only thing between two organizations and one public name |
183
+ | OrgEntity | `iamKey` | `{ iamKey: 1 }` | unique — a duplicate would hand one organization another's realm |
184
+ | OrgEntity | `formerSlugs` | `{ formerSlugs: 1 }` | not unique; a retired slug must stay findable |
111
185
  | Account | `credential` | `{ credential: 1 }` | unique |
112
186
  | Account | `entityId` | `{ entityId: 1 }` | |
113
187
  | Account | `secret` | `{ secret: 1 }` | unique, sparse |
188
+ | Account | `name` | `{ name: 1 }` | not unique — one address is legitimately both a platform account and an end-user row |
114
189
  | Profile | `userId` | `{ userId: 1 }` | |
115
190
  | Profile | `entityId` | `{ entityId: 1 }` | |
116
191
  | Profile | `role` | `{ role: 1, entityId: 1 }` | |
@@ -118,32 +193,42 @@ by the reference conversion's design.
118
193
  | Credentials | `provider` | `{ type: 1, userId: 1, credential: 1 }` | unique |
119
194
  | Credentials | `profileId` | `{ profileId: 1 }` | |
120
195
 
121
- ## Important Gotchas
122
-
123
- - **`Resource.pick()` is destructive** — it deletes the record it finds. For read-only identity lookups in gates or handlers, use `load(id, field)` for single-field indexed queries or `list(criteria)` for multi-field queries. Never use `pick()` in authorization checks.
124
- - **`IdentityLinkingService` is compatible with `AccountLinkingService`** from `@owlmeans/server-oidc-rp` but defined independently to avoid circular dependency. Both return `AuthPayload`.
125
- - First-login profiles are created with `ALL_SCOPES` and `AuthRole.User` — the gate layer is initially permissive per entity. Restrict scopes as needed for fine-grained authorization.
126
- - Account `credential` slug generation retries up to 5 times on duplicate key collision — this is the designed behavior for the Base58 slug space.
127
-
128
- ## Product-Viable Usage Notes
129
-
130
- - Register `appendAuthIdentityResources(context)` immediately after `appendAuthService(context)` so guards can authenticate before product gates authorize.
131
- - Product gates should load `AUTH_IDENTITY_PROFILE` read-only, verify `entityId`, reject expired/blocked profiles, then compare gate params against profile scopes.
132
- - Google login goes through OIDC provider config, but the durable authorization source is the local `IdentityProfile` record.
133
- - Account `credential` is the public local entity slug. Keep it separate from Mongo account `id` and provider subject identifiers.
134
-
135
- ## Relationship to Other Auth Packages
136
-
137
- - **`@owlmeans/server-auth`** — manages the auth guard (token verification). This package manages the identity store underneath.
138
- - **`@owlmeans/server-oidc-rp`** — the OIDC relying party that can call `IdentityLinkingService` during OAuth callback to map external identity to local profile. The linking interface is compatible but independent.
139
- - **`@owlmeans/oidc`** — provides `ProviderProfileDetails` and `OidcProviderDescriptor` types consumed by the linking service and the OIDC client.
196
+ ## Gotchas
197
+
198
+ - **`Resource.take()` is destructive** — it deletes the record it returns. Identity lookups in gates
199
+ and handlers use `load(where)`, which answers a multi-field query in one call
200
+ (`load({ type, userId, credential })`), or `list(where, opts)` for every match. Never `take()` in
201
+ an authorization check.
202
+ - **`IdentityLinkingService` is compatible with `AccountLinkingService`** from
203
+ `@owlmeans/server-oidc-rp` but declared independently, to keep the dependency acyclic. Both return
204
+ `AuthPayload`.
205
+ - First-login profiles are created with `ALL_SCOPES` and `AuthRole.User` — the gate layer starts
206
+ permissive within one organization entity. Narrow the scopes where finer authorization is needed.
207
+ - `mintSlug` throws `SyntaxError('entity:slug-exhausted')` after `MAX_ENTITY_SLUG_ATTEMPTS` word
208
+ bases (three numeric suffixes each). `rename` throws `entity:slug-malformed:<slug>` against
209
+ `ENTITY_SLUG_PATTERN` and `entity:slug-taken:<slug>` for a name any entity has ever answered to.
210
+ - The resolver caches every name a hit was found under — id, slug, former slug, `iamKey` — for 30
211
+ seconds. A rename is therefore visible to other replicas within that window, which is survivable
212
+ precisely because the old slug keeps resolving.
213
+
214
+ ## Relationship to other auth packages
215
+
216
+ - **`@owlmeans/server-auth`** — verifies the bearer token and canonicalizes the slug on the
217
+ credential through this package's resolver. This package is the store underneath.
218
+ - **`@owlmeans/auth-common`** — declares `EntityResolverService` (implemented here),
219
+ `ENTITY_RESOLVER` and `OrgEntityRef`, and carries the `entityKeyOf` / `requireEntityKey` /
220
+ `attachEntity` helpers that read what this package resolves.
221
+ - **`@owlmeans/server-oidc-rp`** — the relying party that calls the linking service during an OAuth
222
+ callback.
223
+ - **`@owlmeans/server-auth-otp`** — the email-OTP plugin resolves its user through the linking
224
+ service.
225
+ - **`@owlmeans/oidc`** — supplies `ProviderProfileDetails` and `OidcProviderDescriptor`.
140
226
 
141
227
  ## Depends On
142
228
 
143
229
  - `@owlmeans/auth` — `AuthPayload`, `AuthRole`, `ALL_SCOPES`, `Profile`, `AuthCredentials`
144
- - `@owlmeans/basic-ids` — `createIdOfLength`, `IdStyle`
145
- - `@owlmeans/context` — `appendContextual`
146
- - `@owlmeans/mongo-resource` — `makeMongoResource`
147
- - `@owlmeans/oidc` — `ProviderProfileDetails`
148
- - `@owlmeans/resource` — `ResourceRecord`
149
- - `@owlmeans/server-context` — `ServerConfig`, `ServerContext`
230
+ - `@owlmeans/auth-common` — `EntityResolverService`, `ENTITY_RESOLVER`, `ENTITY_SLUG_PATTERN`
231
+ - `@owlmeans/basic-ids` — `createIdOfLength`, `IdStyle`, `generateWordSlug`, `nextSlugCandidate`
232
+ - `@owlmeans/mongo-resource` — `makeMongoResource`, declared references and indexes
233
+ - `@owlmeans/context`, `@owlmeans/resource`, `@owlmeans/oidc`, `@owlmeans/server-context`
234
+ - `mongodb` (peer) — the driver behind the Mongo resources this package registers
package/build/consts.d.ts CHANGED
@@ -2,10 +2,13 @@ export declare const AUTH_IDENTITY_ACCOUNT = "auth-identity:account";
2
2
  export declare const AUTH_IDENTITY_PROFILE = "auth-identity:profile";
3
3
  export declare const AUTH_IDENTITY_CREDENTIALS = "auth-identity:credentials";
4
4
  export declare const AUTH_IDENTITY_LINKING = "auth-identity:linking";
5
+ export declare const AUTH_IDENTITY_ORG_ENTITY = "auth-identity:org-entity";
5
6
  export declare const AUTH_IDENTITY_ACCOUNT_COLLECTION = "account";
6
7
  export declare const AUTH_IDENTITY_PROFILE_COLLECTION = "profile";
7
8
  export declare const AUTH_IDENTITY_CREDENTIALS_COLLECTION = "credentials";
9
+ export declare const AUTH_IDENTITY_ORG_ENTITY_COLLECTION = "org-entity";
8
10
  export declare const AUTH_IDENTITY_DB_ALIAS = "auth-identity";
11
+ export declare const MAX_ENTITY_SLUG_ATTEMPTS = 8;
9
12
  export declare const LOGIN_SERVICE_PREFIX = "service";
10
13
  export declare const EXTERNAL_KEY_DELIMITER = ":";
11
14
  //# sourceMappingURL=consts.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,qBAAqB,0BAA0B,CAAA;AAC5D,eAAO,MAAM,qBAAqB,0BAA0B,CAAA;AAC5D,eAAO,MAAM,yBAAyB,8BAA8B,CAAA;AACpE,eAAO,MAAM,qBAAqB,0BAA0B,CAAA;AAM5D,eAAO,MAAM,gCAAgC,YAAY,CAAA;AACzD,eAAO,MAAM,gCAAgC,YAAY,CAAA;AACzD,eAAO,MAAM,oCAAoC,gBAAgB,CAAA;AAIjE,eAAO,MAAM,sBAAsB,kBAAkB,CAAA;AAGrD,eAAO,MAAM,oBAAoB,YAAY,CAAA;AAC7C,eAAO,MAAM,sBAAsB,MAAM,CAAA"}
1
+ {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,qBAAqB,0BAA0B,CAAA;AAC5D,eAAO,MAAM,qBAAqB,0BAA0B,CAAA;AAC5D,eAAO,MAAM,yBAAyB,8BAA8B,CAAA;AACpE,eAAO,MAAM,qBAAqB,0BAA0B,CAAA;AAC5D,eAAO,MAAM,wBAAwB,6BAA6B,CAAA;AAMlE,eAAO,MAAM,gCAAgC,YAAY,CAAA;AACzD,eAAO,MAAM,gCAAgC,YAAY,CAAA;AACzD,eAAO,MAAM,oCAAoC,gBAAgB,CAAA;AACjE,eAAO,MAAM,mCAAmC,eAAe,CAAA;AAI/D,eAAO,MAAM,sBAAsB,kBAAkB,CAAA;AAIrD,eAAO,MAAM,wBAAwB,IAAI,CAAA;AAGzC,eAAO,MAAM,oBAAoB,YAAY,CAAA;AAC7C,eAAO,MAAM,sBAAsB,MAAM,CAAA"}
package/build/consts.js CHANGED
@@ -6,6 +6,7 @@ export const AUTH_IDENTITY_ACCOUNT = 'auth-identity:account';
6
6
  export const AUTH_IDENTITY_PROFILE = 'auth-identity:profile';
7
7
  export const AUTH_IDENTITY_CREDENTIALS = 'auth-identity:credentials';
8
8
  export const AUTH_IDENTITY_LINKING = 'auth-identity:linking';
9
+ export const AUTH_IDENTITY_ORG_ENTITY = 'auth-identity:org-entity';
9
10
  // Colon-free Mongo collection base names. The final collection name is
10
11
  // `${resourcePrefix}${collection}` — e.g. with the project prefix 'viam-' this yields
11
12
  // 'viam-account'. Register the identity resources against a db config that carries the
@@ -13,9 +14,13 @@ export const AUTH_IDENTITY_LINKING = 'auth-identity:linking';
13
14
  export const AUTH_IDENTITY_ACCOUNT_COLLECTION = 'account';
14
15
  export const AUTH_IDENTITY_PROFILE_COLLECTION = 'profile';
15
16
  export const AUTH_IDENTITY_CREDENTIALS_COLLECTION = 'credentials';
17
+ export const AUTH_IDENTITY_ORG_ENTITY_COLLECTION = 'org-entity';
16
18
  // Suggested db-config alias for a dedicated config whose resourcePrefix scopes the
17
19
  // collection prefix to identity collections only (not the whole database).
18
20
  export const AUTH_IDENTITY_DB_ALIAS = 'auth-identity';
21
+ // How many slugs to try before giving up on minting a free one. Collisions are settled against a
22
+ // unique index, so this bounds a retry loop rather than a probability.
23
+ export const MAX_ENTITY_SLUG_ATTEMPTS = 8;
19
24
  // Helper prefixes for stable key derivation
20
25
  export const LOGIN_SERVICE_PREFIX = 'service';
21
26
  export const EXTERNAL_KEY_DELIMITER = ':';
@@ -1 +1 @@
1
- {"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,wFAAwF;AACxF,sFAAsF;AACtF,sFAAsF;AACtF,uFAAuF;AACvF,MAAM,CAAC,MAAM,qBAAqB,GAAG,uBAAuB,CAAA;AAC5D,MAAM,CAAC,MAAM,qBAAqB,GAAG,uBAAuB,CAAA;AAC5D,MAAM,CAAC,MAAM,yBAAyB,GAAG,2BAA2B,CAAA;AACpE,MAAM,CAAC,MAAM,qBAAqB,GAAG,uBAAuB,CAAA;AAE5D,uEAAuE;AACvE,sFAAsF;AACtF,uFAAuF;AACvF,2FAA2F;AAC3F,MAAM,CAAC,MAAM,gCAAgC,GAAG,SAAS,CAAA;AACzD,MAAM,CAAC,MAAM,gCAAgC,GAAG,SAAS,CAAA;AACzD,MAAM,CAAC,MAAM,oCAAoC,GAAG,aAAa,CAAA;AAEjE,mFAAmF;AACnF,2EAA2E;AAC3E,MAAM,CAAC,MAAM,sBAAsB,GAAG,eAAe,CAAA;AAErD,4CAA4C;AAC5C,MAAM,CAAC,MAAM,oBAAoB,GAAG,SAAS,CAAA;AAC7C,MAAM,CAAC,MAAM,sBAAsB,GAAG,GAAG,CAAA"}
1
+ {"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,wFAAwF;AACxF,sFAAsF;AACtF,sFAAsF;AACtF,uFAAuF;AACvF,MAAM,CAAC,MAAM,qBAAqB,GAAG,uBAAuB,CAAA;AAC5D,MAAM,CAAC,MAAM,qBAAqB,GAAG,uBAAuB,CAAA;AAC5D,MAAM,CAAC,MAAM,yBAAyB,GAAG,2BAA2B,CAAA;AACpE,MAAM,CAAC,MAAM,qBAAqB,GAAG,uBAAuB,CAAA;AAC5D,MAAM,CAAC,MAAM,wBAAwB,GAAG,0BAA0B,CAAA;AAElE,uEAAuE;AACvE,sFAAsF;AACtF,uFAAuF;AACvF,2FAA2F;AAC3F,MAAM,CAAC,MAAM,gCAAgC,GAAG,SAAS,CAAA;AACzD,MAAM,CAAC,MAAM,gCAAgC,GAAG,SAAS,CAAA;AACzD,MAAM,CAAC,MAAM,oCAAoC,GAAG,aAAa,CAAA;AACjE,MAAM,CAAC,MAAM,mCAAmC,GAAG,YAAY,CAAA;AAE/D,mFAAmF;AACnF,2EAA2E;AAC3E,MAAM,CAAC,MAAM,sBAAsB,GAAG,eAAe,CAAA;AAErD,iGAAiG;AACjG,uEAAuE;AACvE,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,CAAA;AAEzC,4CAA4C;AAC5C,MAAM,CAAC,MAAM,oBAAoB,GAAG,SAAS,CAAA;AAC7C,MAAM,CAAC,MAAM,sBAAsB,GAAG,GAAG,CAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"helper.d.ts","sourceRoot":"","sources":["../src/helper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAIrD,eAAO,MAAM,2BAA2B,YAC7B,YAAY,CAAC,GAAG,CAAC,YAChB,MAAM,KACf,IAKF,CAAA"}
1
+ {"version":3,"file":"helper.d.ts","sourceRoot":"","sources":["../src/helper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAKrD,eAAO,MAAM,2BAA2B,YAC7B,YAAY,CAAC,GAAG,CAAC,YAChB,MAAM,KACf,IAUF,CAAA"}
package/build/helper.js CHANGED
@@ -1,9 +1,15 @@
1
- import { makeIdentityAccountResource, makeIdentityProfileResource, makeIdentityCredentialsResource } from './resource.js';
1
+ import { makeIdentityAccountResource, makeIdentityProfileResource, makeIdentityCredentialsResource, makeOrgEntityResource } from './resource.js';
2
2
  import { makeIdentityLinkingService } from './service.js';
3
+ import { makeEntityResolverService } from './resolver.js';
3
4
  export const appendAuthIdentityResources = (context, dbAlias) => {
4
5
  context.registerResource(makeIdentityAccountResource(dbAlias));
5
6
  context.registerResource(makeIdentityProfileResource(dbAlias));
6
7
  context.registerResource(makeIdentityCredentialsResource(dbAlias));
8
+ context.registerResource(makeOrgEntityResource(dbAlias));
7
9
  context.registerService(makeIdentityLinkingService());
10
+ // Registering the resolver is what tells the server boundary that this deployment HAS
11
+ // organizations: without it `request.entity` stays undefined and every consumer falls back to
12
+ // treating the token's slug as the only entity value there is.
13
+ context.registerService(makeEntityResolverService());
8
14
  };
9
15
  //# sourceMappingURL=helper.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"helper.js","sourceRoot":"","sources":["../src/helper.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,2BAA2B,EAAE,2BAA2B,EAAE,+BAA+B,EAAE,MAAM,eAAe,CAAA;AACzH,OAAO,EAAE,0BAA0B,EAAE,MAAM,cAAc,CAAA;AAEzD,MAAM,CAAC,MAAM,2BAA2B,GAAG,CACzC,OAA0B,EAC1B,OAAgB,EACV,EAAE;IACR,OAAO,CAAC,gBAAgB,CAAC,2BAA2B,CAAC,OAAO,CAAC,CAAC,CAAA;IAC9D,OAAO,CAAC,gBAAgB,CAAC,2BAA2B,CAAC,OAAO,CAAC,CAAC,CAAA;IAC9D,OAAO,CAAC,gBAAgB,CAAC,+BAA+B,CAAC,OAAO,CAAC,CAAC,CAAA;IAClE,OAAO,CAAC,eAAe,CAAC,0BAA0B,EAAE,CAAC,CAAA;AACvD,CAAC,CAAA"}
1
+ {"version":3,"file":"helper.js","sourceRoot":"","sources":["../src/helper.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,2BAA2B,EAAE,2BAA2B,EAAE,+BAA+B,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAA;AAChJ,OAAO,EAAE,0BAA0B,EAAE,MAAM,cAAc,CAAA;AACzD,OAAO,EAAE,yBAAyB,EAAE,MAAM,eAAe,CAAA;AAEzD,MAAM,CAAC,MAAM,2BAA2B,GAAG,CACzC,OAA0B,EAC1B,OAAgB,EACV,EAAE;IACR,OAAO,CAAC,gBAAgB,CAAC,2BAA2B,CAAC,OAAO,CAAC,CAAC,CAAA;IAC9D,OAAO,CAAC,gBAAgB,CAAC,2BAA2B,CAAC,OAAO,CAAC,CAAC,CAAA;IAC9D,OAAO,CAAC,gBAAgB,CAAC,+BAA+B,CAAC,OAAO,CAAC,CAAC,CAAA;IAClE,OAAO,CAAC,gBAAgB,CAAC,qBAAqB,CAAC,OAAO,CAAC,CAAC,CAAA;IACxD,OAAO,CAAC,eAAe,CAAC,0BAA0B,EAAE,CAAC,CAAA;IACrD,sFAAsF;IACtF,8FAA8F;IAC9F,+DAA+D;IAC/D,OAAO,CAAC,eAAe,CAAC,yBAAyB,EAAE,CAAC,CAAA;AACtD,CAAC,CAAA"}
package/build/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export * from './consts.js';
2
2
  export type * from './types.js';
3
3
  export * from './resource.js';
4
4
  export * from './service.js';
5
+ export * from './resolver.js';
5
6
  export * from './helper.js';
6
7
  export * from './iam.js';
7
8
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAA;AAC3B,mBAAmB,YAAY,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,UAAU,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAA;AAC3B,mBAAmB,YAAY,CAAA;AAC/B,cAAc,eAAe,CAAA;AAC7B,cAAc,cAAc,CAAA;AAC5B,cAAc,eAAe,CAAA;AAC7B,cAAc,aAAa,CAAA;AAC3B,cAAc,UAAU,CAAA"}
package/build/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './consts.js';
2
2
  export * from './resource.js';
3
3
  export * from './service.js';
4
+ export * from './resolver.js';
4
5
  export * from './helper.js';
5
6
  export * from './iam.js';
6
7
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAA;AAE3B,cAAc,eAAe,CAAA;AAC7B,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,UAAU,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAA;AAE3B,cAAc,eAAe,CAAA;AAC7B,cAAc,cAAc,CAAA;AAC5B,cAAc,eAAe,CAAA;AAC7B,cAAc,aAAa,CAAA;AAC3B,cAAc,UAAU,CAAA"}
@@ -0,0 +1,3 @@
1
+ import type { EntityResolverService } from '@owlmeans/auth-common';
2
+ export declare const makeEntityResolverService: (alias?: string) => EntityResolverService;
3
+ //# sourceMappingURL=resolver.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolver.d.ts","sourceRoot":"","sources":["../src/resolver.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,qBAAqB,EAAgB,MAAM,uBAAuB,CAAA;AA6BhF,eAAO,MAAM,yBAAyB,WAC7B,MAAM,KACZ,qBAkIF,CAAA"}
@@ -0,0 +1,133 @@
1
+ import { appendContextual } from '@owlmeans/context';
2
+ import { ENTITY_RESOLVER, ENTITY_SLUG_PATTERN } from '@owlmeans/auth-common';
3
+ import { generateWordSlug, nextSlugCandidate } from '@owlmeans/basic-ids';
4
+ import { AUTH_IDENTITY_ORG_ENTITY, MAX_ENTITY_SLUG_ATTEMPTS } from './consts.js';
5
+ /**
6
+ * How long a resolution is trusted without re-reading the registry.
7
+ *
8
+ * Every authenticated request resolves an entity, so an uncached resolver would put a database
9
+ * round-trip in front of the whole API. The cost of the cache is a rename taking up to this long
10
+ * to be seen by other replicas — which is survivable precisely because the old slug keeps
11
+ * resolving through `formerSlugs` rather than failing.
12
+ */
13
+ const CACHE_TTL = 30_000;
14
+ /** A stored record as the contract exposes it. Persisted records always carry an id. */
15
+ const toRef = (entity) => entity == null ? null : {
16
+ id: entity.id, slug: entity.slug, formerSlugs: entity.formerSlugs, iamKey: entity.iamKey,
17
+ };
18
+ export const makeEntityResolverService = (alias = ENTITY_RESOLVER) => {
19
+ // Keyed by every name a caller might arrive with — id, slug, former slug, iamKey — so a hit
20
+ // costs one lookup regardless of which one it was.
21
+ const cache = new Map();
22
+ const now = () => Date.now();
23
+ const remember = (entity, ...keys) => {
24
+ const at = now();
25
+ for (const key of keys) {
26
+ if (key !== '')
27
+ cache.set(key, { entity, at });
28
+ }
29
+ if (entity != null) {
30
+ cache.set(entity.id, { entity, at });
31
+ cache.set(entity.slug, { entity, at });
32
+ cache.set(entity.iamKey, { entity, at });
33
+ for (const former of entity.formerSlugs ?? [])
34
+ cache.set(former, { entity, at });
35
+ }
36
+ return entity;
37
+ };
38
+ const forget = (entity) => {
39
+ for (const key of [entity.id, entity.slug, entity.iamKey, ...(entity.formerSlugs ?? [])]) {
40
+ if (key != null)
41
+ cache.delete(key);
42
+ }
43
+ };
44
+ const resource = () => service.ctx.resource(AUTH_IDENTITY_ORG_ENTITY);
45
+ const load = async (value) => {
46
+ const cached = cache.get(value);
47
+ if (cached != null && now() - cached.at < CACHE_TTL) {
48
+ return cached.entity;
49
+ }
50
+ const res = resource();
51
+ // Id first: an id is authoritative, so a slug that happens to look like some other entity's
52
+ // id can never shadow it. A value that is not a record id simply misses. Then the current
53
+ // slug, then the names it has retired, then the frozen key external systems still quote.
54
+ const byId = await res.load(value);
55
+ if (byId != null)
56
+ return remember(byId, value);
57
+ const candidates = [
58
+ { slug: value },
59
+ { formerSlugs: { $contains: [value] } },
60
+ { iamKey: value },
61
+ ];
62
+ for (const where of candidates) {
63
+ const found = await res.load(where);
64
+ if (found != null)
65
+ return remember(found, value);
66
+ }
67
+ return remember(null, value);
68
+ };
69
+ const service = appendContextual(alias, {
70
+ resolve: async (value) => toRef(await load(value)),
71
+ byId: async (id) => toRef(remember(await resource().load(id), id)),
72
+ mintSlug: async () => {
73
+ const res = resource();
74
+ // A word slug is picked from 2^22 pairs, so a collision is rare — but "rare" is not "never"
75
+ // once an installation has many organizations, and the numeric suffix keeps the readable
76
+ // name rather than rerolling into an unrelated one.
77
+ for (let attempt = 1; attempt <= MAX_ENTITY_SLUG_ATTEMPTS; ++attempt) {
78
+ const base = generateWordSlug();
79
+ for (let suffix = 1; suffix <= 3; ++suffix) {
80
+ const candidate = nextSlugCandidate(base, suffix);
81
+ const taken = await res.load({
82
+ $or: [{ slug: candidate }, { formerSlugs: { $contains: [candidate] } }],
83
+ });
84
+ if (taken == null)
85
+ return candidate;
86
+ }
87
+ }
88
+ throw new SyntaxError('entity:slug-exhausted');
89
+ },
90
+ rename: async (id, slug) => {
91
+ if (!ENTITY_SLUG_PATTERN.test(slug)) {
92
+ throw new SyntaxError(`entity:slug-malformed:${slug}`);
93
+ }
94
+ const res = resource();
95
+ const entity = await res.get(id);
96
+ if (entity.slug === slug) {
97
+ return toRef(entity);
98
+ }
99
+ // A slug another entity has ever answered to cannot be taken: tokens and third-party records
100
+ // quoting it would start resolving to the wrong organization.
101
+ const taken = await res.load({
102
+ $or: [{ slug }, { formerSlugs: { $contains: [slug] } }],
103
+ });
104
+ if (taken != null && taken.id !== id) {
105
+ throw new SyntaxError(`entity:slug-taken:${slug}`);
106
+ }
107
+ forget(entity);
108
+ const formerSlugs = [...new Set([...(entity.formerSlugs ?? []), entity.slug])]
109
+ .filter(former => former !== slug);
110
+ const updated = await res.update({ ...entity, slug, formerSlugs, updatedAt: new Date() });
111
+ return toRef(remember(updated));
112
+ },
113
+ mintName: async (id, key, mint) => {
114
+ const res = resource();
115
+ const entity = await res.get(id);
116
+ const existing = entity.names?.[key];
117
+ // Read-before-mint is what freezes the name: the second caller gets the first caller's
118
+ // answer, so a rename between the two cannot produce two names for one live resource.
119
+ if (existing != null && existing !== '') {
120
+ return existing;
121
+ }
122
+ const minted = mint(toRef(entity));
123
+ forget(entity);
124
+ const updated = await res.update({
125
+ ...entity, names: { ...entity.names, [key]: minted }, updatedAt: new Date(),
126
+ });
127
+ remember(updated);
128
+ return updated.names?.[key] ?? minted;
129
+ },
130
+ });
131
+ return service;
132
+ };
133
+ //# sourceMappingURL=resolver.js.map