@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 +7 -6
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-auth-identity/SKILL.md +165 -80
- package/build/consts.d.ts +3 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +5 -0
- package/build/consts.js.map +1 -1
- package/build/helper.d.ts.map +1 -1
- package/build/helper.js +7 -1
- package/build/helper.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/resolver.d.ts +3 -0
- package/build/resolver.d.ts.map +1 -0
- package/build/resolver.js +133 -0
- package/build/resolver.js.map +1 -0
- package/build/resource.d.ts +11 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +30 -4
- package/build/resource.js.map +1 -1
- package/build/service.d.ts.map +1 -1
- package/build/service.js +115 -25
- package/build/service.js.map +1 -1
- package/build/types.d.ts +41 -3
- package/build/types.d.ts.map +1 -1
- package/package.json +10 -9
- package/src/consts.ts +6 -0
- package/src/helper.ts +7 -1
- package/src/index.ts +1 -0
- package/src/resolver.ts +165 -0
- package/src/resource.ts +36 -6
- package/src/service.ts +130 -28
- package/src/types.ts +43 -3
- package/tests/linking.spec.ts +202 -0
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
|
|
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
|
|
46
|
+
const profile = await profiles.load({ profileId })
|
|
47
|
+
const scoped = await profiles.load({ entityId, profileId })
|
|
47
48
|
|
|
48
|
-
const { items } = await profiles.list({ entityId
|
|
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.
|
|
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
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/server-auth-identity",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-
|
|
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
|
|
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.
|
|
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
|
|
18
|
-
| `
|
|
19
|
-
| `
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
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
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
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
|
-
//
|
|
92
|
+
// in makeContext, after the Mongo/Redis services and appendAuthService:
|
|
44
93
|
appendAuthIdentityResources(context)
|
|
45
94
|
```
|
|
46
95
|
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
61
|
-
const { items } = await
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
by the
|
|
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
|
-
|
|
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
|
-
##
|
|
122
|
-
|
|
123
|
-
- **`Resource.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
-
|
|
131
|
-
|
|
132
|
-
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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/
|
|
145
|
-
- `@owlmeans/
|
|
146
|
-
- `@owlmeans/mongo-resource` — `makeMongoResource
|
|
147
|
-
- `@owlmeans/oidc
|
|
148
|
-
-
|
|
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
|
package/build/consts.d.ts.map
CHANGED
|
@@ -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;
|
|
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 = ':';
|
package/build/consts.js.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/build/helper.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
package/build/helper.js.map
CHANGED
|
@@ -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;
|
|
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
package/build/index.d.ts.map
CHANGED
|
@@ -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
package/build/index.js.map
CHANGED
|
@@ -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 @@
|
|
|
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
|