@owlmeans/server-auth-identity 0.1.18-rc.49 → 0.1.18-rc.50
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 +2 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-auth-identity/SKILL.md +12 -3
- package/build/consts.local.d.ts +5 -0
- package/build/consts.local.d.ts.map +1 -1
- package/build/consts.local.js +5 -0
- package/build/consts.local.js.map +1 -1
- package/build/groups/types.d.ts +13 -0
- package/build/groups/types.d.ts.map +1 -1
- package/build/groups.d.ts.map +1 -1
- package/build/groups.js +11 -1
- package/build/groups.js.map +1 -1
- package/build/iam.d.ts +22 -0
- package/build/iam.d.ts.map +1 -0
- package/build/iam.js +2 -0
- package/build/iam.js.map +1 -0
- package/build/native.d.ts +14 -0
- package/build/native.d.ts.map +1 -0
- package/build/native.js +12 -0
- package/build/native.js.map +1 -0
- package/package.json +4 -4
- package/src/consts.local.ts +6 -0
- package/src/groups/types.ts +13 -0
- package/src/groups.ts +17 -2
- package/tests/context.ts +12 -4
- package/tests/resolver.spec.ts +37 -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.
|
|
13
|
+
bun add @owlmeans/server-auth-identity@^0.1.18-rc.50
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
## Concepts
|
|
@@ -309,7 +309,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
309
309
|
your project's skill store (`.agents/skills/`):
|
|
310
310
|
|
|
311
311
|
```sh
|
|
312
|
-
npx @owlmeans/agent-skills@^0.1.18-rc.
|
|
312
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.54
|
|
313
313
|
```
|
|
314
314
|
|
|
315
315
|
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-10-
|
|
4
|
+
"version": "0.1.18-rc.50",
|
|
5
|
+
"generatedAt": "2026-10-15T10:20:01.287Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -8,7 +8,7 @@ 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.50"` in `dependencies`
|
|
12
12
|
|
|
13
13
|
The identity store a deployment owns when it does not delegate identity to an external IAM. It
|
|
14
14
|
answers *who is this person* (account + credentials), *what are they in this app and organization*
|
|
@@ -25,7 +25,7 @@ serves the deployment's own app and every app it hosts (a platform's generated t
|
|
|
25
25
|
| `identityOf(ctx).ensureAccount({ email, name? }, details?)` | The person's account: by the method's credential, then by e-mail, else registered with a personal organization; attaches `details` as a credential |
|
|
26
26
|
| `identityOf(ctx).ensureProfile({ account, service, entityId, owner?, role?, scopes?, permissions?, groups?, managed? })` | Find-or-create the row of (account, app, organization); ensures the primary row first |
|
|
27
27
|
| `identityKeyHelper.credentialKeyOf(details)`, `identityOf(ctx).credentialOf(details)`, `identityKeyHelper.normalizeEmail(email)` | A method's unique credential key; its stored row and account; the stored address form |
|
|
28
|
-
| `orgGroupsOf(ctx)` — `.listOrgGroups(entityId, service)`, `.putOrgGroup(entityId, group)`, `.removeOrgGroup(entityId, service, key)` | An organization's groups of one app — guarded field-level writes, unique per (`service`, `key`) |
|
|
28
|
+
| `orgGroupsOf(ctx)` — `.listOrgGroups(entityId, service)`, `.putOrgGroup(entityId, group)`, `.updateOrgGroup(entityId, service, key, fields)`, `.removeOrgGroup(entityId, service, key)` | An organization's groups of one app — guarded field-level writes, unique per (`service`, `key`); `OrgGroupFields` = `{ title?, managed?, permissions?, bundles? }` |
|
|
29
29
|
| `makeOrgEntityResource`, `makeIdentityAccountResource`, `makeIdentityProfileResource`, `makeIdentityCredentialsResource` | Mongo resource makers (`dbAlias?`) |
|
|
30
30
|
| `makeIdentityLinkingService({ service? })` | The `IdentityLinkingService` for one app |
|
|
31
31
|
| `makeEntityResolverService(alias?)` | The `EntityResolverService`, cached 30 s per resolved value (slim references only) |
|
|
@@ -80,6 +80,14 @@ it read (`formerSlugs` moves only with it), `mintName` on the name still being u
|
|
|
80
80
|
wins, both answer what was stored), `orgGroupsOf(ctx).putOrgGroup` replaces the matching element (`groups.$`) or
|
|
81
81
|
pushes where none matches (`$not: { $elemMatch }`) and retries if a concurrent put won.
|
|
82
82
|
|
|
83
|
+
**Inside a group the same rule holds: create with `putOrgGroup`, change with `updateOrgGroup`.**
|
|
84
|
+
`updateOrgGroup(entityId, service, key, fields)` `$set`s `groups.$.<field>` (and `updatedAt`) for
|
|
85
|
+
only the fields passed — `title`, `managed`, `permissions`, `bundles`; `service` / `key` are never
|
|
86
|
+
rewritten — on the element `$elemMatch` selects, and answers `true` when that group existed. It never
|
|
87
|
+
creates one. Two writers owning different fields of one group (a platform sync keeping `managed` +
|
|
88
|
+
`bundles`, an operator granting into `permissions`) keep each other's work; a `putOrgGroup` from
|
|
89
|
+
either one's stale read would undo the other's.
|
|
90
|
+
|
|
83
91
|
## Usage
|
|
84
92
|
|
|
85
93
|
### Register in a context
|
|
@@ -204,7 +212,8 @@ across organizations, not inside one.
|
|
|
204
212
|
|
|
205
213
|
- **`Resource.take()` deletes** the record it returns — identity reads in gates use `load` / `list`.
|
|
206
214
|
- **Never `update()` an org-entity** (a whole-record replace); use the resolver, the group helpers or
|
|
207
|
-
a guarded `collection.updateOne`.
|
|
215
|
+
a guarded `collection.updateOne`. Never `putOrgGroup` an existing group to change one field of it —
|
|
216
|
+
`updateOrgGroup` that field.
|
|
208
217
|
- **`IdentityLinkingService` is compatible with `AccountLinkingService`** of
|
|
209
218
|
`@owlmeans/server-oidc-rp` but declared independently (acyclic dependencies).
|
|
210
219
|
- **The address is the identity.** Whoever calls `identityOf(ctx).ensureAccount` / `linkProfile` with an address is
|
package/build/consts.local.d.ts
CHANGED
|
@@ -7,4 +7,9 @@
|
|
|
7
7
|
* resolving through `formerSlugs` rather than failing.
|
|
8
8
|
*/
|
|
9
9
|
export declare const CACHE_TTL = 30000;
|
|
10
|
+
/**
|
|
11
|
+
* The fields of an organization group a field-level update may write. `service` and `key` are the
|
|
12
|
+
* group's identity and are never rewritten in place.
|
|
13
|
+
*/
|
|
14
|
+
export declare const ORG_GROUP_FIELDS: readonly ['title', 'managed', 'permissions', 'bundles'];
|
|
10
15
|
//# sourceMappingURL=consts.local.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"consts.local.d.ts","sourceRoot":"","sources":["../src/consts.local.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,eAAO,MAAM,SAAS,QAAS,CAAA"}
|
|
1
|
+
{"version":3,"file":"consts.local.d.ts","sourceRoot":"","sources":["../src/consts.local.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,eAAO,MAAM,SAAS,QAAS,CAAA;AAE/B;;;GAGG;AACH,eAAO,MAAM,gBAAgB,YAAI,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,SAAS,CAAU,CAAA"}
|
package/build/consts.local.js
CHANGED
|
@@ -7,4 +7,9 @@
|
|
|
7
7
|
* resolving through `formerSlugs` rather than failing.
|
|
8
8
|
*/
|
|
9
9
|
export const CACHE_TTL = 30_000;
|
|
10
|
+
/**
|
|
11
|
+
* The fields of an organization group a field-level update may write. `service` and `key` are the
|
|
12
|
+
* group's identity and are never rewritten in place.
|
|
13
|
+
*/
|
|
14
|
+
export const ORG_GROUP_FIELDS = ['title', 'managed', 'permissions', 'bundles'];
|
|
10
15
|
//# sourceMappingURL=consts.local.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"consts.local.js","sourceRoot":"","sources":["../src/consts.local.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,MAAM,CAAA"}
|
|
1
|
+
{"version":3,"file":"consts.local.js","sourceRoot":"","sources":["../src/consts.local.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,MAAM,CAAA;AAE/B;;;GAGG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,SAAS,CAAU,CAAA"}
|
package/build/groups/types.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { OrgGroup } from '../types.js';
|
|
2
|
+
/** What `updateOrgGroup` may write — each field present is set, each absent one is left alone. */
|
|
3
|
+
export interface OrgGroupFields extends Partial<Pick<OrgGroup, 'title' | 'managed' | 'permissions' | 'bundles'>> {
|
|
4
|
+
}
|
|
2
5
|
/** The groups apps keep in organizations of one context's identity store. */
|
|
3
6
|
export interface OrgGroupHelper {
|
|
4
7
|
/** The groups one app keeps in an organization. */
|
|
@@ -14,6 +17,16 @@ export interface OrgGroupHelper {
|
|
|
14
17
|
* @throws {UnknownRecordError} when the organization does not exist.
|
|
15
18
|
*/
|
|
16
19
|
putOrgGroup: (entityId: string, group: OrgGroup) => Promise<OrgGroup>;
|
|
20
|
+
/**
|
|
21
|
+
* Set only the given fields (`title`, `managed`, `permissions`, `bundles`) of the EXISTING group
|
|
22
|
+
* (`service`, `key`) — `true` when it matched; it never creates one.
|
|
23
|
+
*
|
|
24
|
+
* Two writers of one group that own different fields — a platform sync keeping `managed` and
|
|
25
|
+
* `bundles`, an operator granting into `permissions` — must not undo each other, and
|
|
26
|
+
* `putOrgGroup` replaces the whole element from whatever its caller read. Use `putOrgGroup` only
|
|
27
|
+
* to create a group; change an existing one through this.
|
|
28
|
+
*/
|
|
29
|
+
updateOrgGroup: (entityId: string, service: string, key: string, fields: OrgGroupFields) => Promise<boolean>;
|
|
17
30
|
/**
|
|
18
31
|
* Remove the group (`service`, `key`) from an organization — `true` when it was there. The rows that
|
|
19
32
|
* name it in their `groups` are the caller's to update.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/groups/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAE3C,6EAA6E;AAC7E,MAAM,WAAW,cAAc;IAC7B,mDAAmD;IACnD,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAA;IACzE;;;;;;;;;OASG;IACH,WAAW,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;IACrE;;;OAGG;IACH,cAAc,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CACrF"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/groups/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAE3C,kGAAkG;AAClG,MAAM,WAAW,cAAe,SAAQ,OAAO,CAAC,IAAI,CAAC,QAAQ,EAAE,OAAO,GAAG,SAAS,GAAG,aAAa,GAAG,SAAS,CAAC,CAAC;CAAG;AAEnH,6EAA6E;AAC7E,MAAM,WAAW,cAAc;IAC7B,mDAAmD;IACnD,aAAa,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAA;IACzE;;;;;;;;;OASG;IACH,WAAW,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;IACrE;;;;;;;;OAQG;IACH,cAAc,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IAC5G;;;OAGG;IACH,cAAc,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CACrF"}
|
package/build/groups.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"groups.d.ts","sourceRoot":"","sources":["../src/groups.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAqB,QAAQ,EAAE,MAAM,YAAY,CAAA;AAG7D,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"groups.d.ts","sourceRoot":"","sources":["../src/groups.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAqB,QAAQ,EAAE,MAAM,YAAY,CAAA;AAG7D,OAAO,KAAK,EAAkB,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACvE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AAG/C,eAAO,MAAM,kBAAkB,QAAS,OAAO,KAAG,cA4DjD,CAAA;AAED,8DAA8D;AAC9D,eAAO,MAAM,WAAW,qCAAyC,CAAA;AAEjE,oFAAoF;AACpF,eAAO,MAAM,aAAa,QAAe,OAAO,YAAY,MAAM,WAAW,MAAM,KAAG,OAAO,CAAC,QAAQ,EAAE,CAC/C,CAAA;AAEzD,kFAAkF;AAClF,eAAO,MAAM,WAAW,QAAe,OAAO,YAAY,MAAM,SAAS,QAAQ,KAAG,OAAO,CAAC,QAAQ,CAC/C,CAAA;AAErD,qFAAqF;AACrF,eAAO,MAAM,cAAc,QAAe,OAAO,YAAY,MAAM,WAAW,MAAM,OAAO,MAAM,KAAG,OAAO,CAAC,OAAO,CACpD,CAAA"}
|
package/build/groups.js
CHANGED
|
@@ -2,6 +2,7 @@ import { UnknownRecordError } from '@owlmeans/resource';
|
|
|
2
2
|
import { memoHelper } from '@owlmeans/context';
|
|
3
3
|
import { AUTH_IDENTITY_ORG_ENTITY, MAX_GUARDED_UPDATE_ATTEMPTS } from './consts.js';
|
|
4
4
|
import { nativeUtils } from './utils/native.js';
|
|
5
|
+
import { ORG_GROUP_FIELDS } from './consts.local.js';
|
|
5
6
|
export const makeOrgGroupHelper = (ctx) => {
|
|
6
7
|
const entities = () => ctx.resource(AUTH_IDENTITY_ORG_ENTITY);
|
|
7
8
|
const listOrgGroups = async (entityId, service) => ((await entities().get(entityId)).groups ?? []).filter(group => group.service === service);
|
|
@@ -28,13 +29,22 @@ export const makeOrgGroupHelper = (ctx) => {
|
|
|
28
29
|
}
|
|
29
30
|
throw new SyntaxError(`entity:group-contended:${group.service}:${group.key}`);
|
|
30
31
|
};
|
|
32
|
+
const updateOrgGroup = async (entityId, service, key, fields) => {
|
|
33
|
+
const res = entities();
|
|
34
|
+
// Positional paths of the matched element only: the other fields of the group, and every
|
|
35
|
+
// other group of the organization, stay exactly as the store has them now.
|
|
36
|
+
const set = Object.fromEntries(ORG_GROUP_FIELDS.filter(field => fields[field] !== undefined)
|
|
37
|
+
.map(field => [`groups.$.${field}`, fields[field]]));
|
|
38
|
+
const result = await res.collection.updateOne({ ...nativeUtils.idFilter(res, entityId), groups: { $elemMatch: { service, key } } }, { $set: { ...set, updatedAt: new Date() } });
|
|
39
|
+
return result.matchedCount > 0;
|
|
40
|
+
};
|
|
31
41
|
const removeOrgGroup = async (entityId, service, key) => {
|
|
32
42
|
const res = entities();
|
|
33
43
|
const pull = { $pull: { groups: { service, key } }, $set: { updatedAt: new Date() } };
|
|
34
44
|
const result = await res.collection.updateOne({ ...nativeUtils.idFilter(res, entityId), groups: { $elemMatch: { service, key } } }, pull);
|
|
35
45
|
return result.matchedCount > 0;
|
|
36
46
|
};
|
|
37
|
-
return { listOrgGroups, putOrgGroup, removeOrgGroup };
|
|
47
|
+
return { listOrgGroups, putOrgGroup, updateOrgGroup, removeOrgGroup };
|
|
38
48
|
};
|
|
39
49
|
/** The organization groups of a context — one per context. */
|
|
40
50
|
export const orgGroupsOf = memoHelper.oncePer(makeOrgGroupHelper);
|
package/build/groups.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"groups.js","sourceRoot":"","sources":["../src/groups.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AACvD,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAG9C,OAAO,EAAE,wBAAwB,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAA;AACnF,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;
|
|
1
|
+
{"version":3,"file":"groups.js","sourceRoot":"","sources":["../src/groups.ts"],"names":[],"mappings":"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"}
|
package/build/iam.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AuthPayload } from '@owlmeans/auth';
|
|
2
|
+
/** Find-or-create an end-user identity scoped to an entity by email address.
|
|
3
|
+
*
|
|
4
|
+
* Used by the OTP auth plugin: after code verification, look up whether this
|
|
5
|
+
* email already has a profile under the entity and create one if not.
|
|
6
|
+
*/
|
|
7
|
+
export interface EmailIdentityArgs {
|
|
8
|
+
email: string;
|
|
9
|
+
/** The entity (project owner) this login is scoped to */
|
|
10
|
+
entityId?: string;
|
|
11
|
+
/** Human-readable display name (defaults to email) */
|
|
12
|
+
username?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Result matches AuthPayload so it can be embedded directly in the credential. */
|
|
15
|
+
export type EmailIdentityPayload = AuthPayload;
|
|
16
|
+
/** Extension seam for IAM-backed identity operations — Phase 2 fills this */
|
|
17
|
+
export interface IdentityIamExtension {
|
|
18
|
+
/** Inputs and result types for the email find-or-create path */
|
|
19
|
+
EmailIdentityArgs: EmailIdentityArgs;
|
|
20
|
+
EmailIdentityPayload: EmailIdentityPayload;
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=iam.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"iam.d.ts","sourceRoot":"","sources":["../src/iam.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAEjD;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,yDAAyD;IACzD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,sDAAsD;IACtD,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,mFAAmF;AACnF,MAAM,MAAM,oBAAoB,GAAG,WAAW,CAAA;AAE9C,6EAA6E;AAC7E,MAAM,WAAW,oBAAoB;IACnC,gEAAgE;IAChE,iBAAiB,EAAE,iBAAiB,CAAA;IACpC,oBAAoB,EAAE,oBAAoB,CAAA;CAC3C"}
|
package/build/iam.js
ADDED
package/build/iam.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"iam.js","sourceRoot":"","sources":["../src/iam.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { MongoResource } from '@owlmeans/mongo-resource';
|
|
2
|
+
import type { ResourceRecord } from '@owlmeans/resource';
|
|
3
|
+
import type { Document } from 'mongodb';
|
|
4
|
+
/**
|
|
5
|
+
* The collection filter addressing one record by id, exactly as the resource itself addresses it —
|
|
6
|
+
* `id` onto `_id`, a 24-hex string as an `ObjectId`, anything else matching nothing.
|
|
7
|
+
*
|
|
8
|
+
* Field-level updates go through the native collection (the resource contract replaces whole
|
|
9
|
+
* records), so they need the id the way the collection stores it.
|
|
10
|
+
*/
|
|
11
|
+
export declare const idFilter: <T extends ResourceRecord>(resource: MongoResource<T>, id: string) => Document;
|
|
12
|
+
/** A unique index refused the write. */
|
|
13
|
+
export declare const isDuplicateKey: (error: unknown) => boolean;
|
|
14
|
+
//# sourceMappingURL=native.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"native.d.ts","sourceRoot":"","sources":["../src/native.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAA;AAC7D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AACxD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAEvC;;;;;;GAMG;AACH,eAAO,MAAM,QAAQ,GAAI,CAAC,SAAS,cAAc,YAAY,aAAa,CAAC,CAAC,CAAC,MAAM,MAAM,KAAG,QACL,CAAA;AAEvF,wCAAwC;AACxC,eAAO,MAAM,cAAc,UAAW,OAAO,KAAG,OACoB,CAAA"}
|
package/build/native.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { criteriaToFilter, MONGO_DUPLICATE_KEY } from '@owlmeans/mongo-resource';
|
|
2
|
+
/**
|
|
3
|
+
* The collection filter addressing one record by id, exactly as the resource itself addresses it —
|
|
4
|
+
* `id` onto `_id`, a 24-hex string as an `ObjectId`, anything else matching nothing.
|
|
5
|
+
*
|
|
6
|
+
* Field-level updates go through the native collection (the resource contract replaces whole
|
|
7
|
+
* records), so they need the id the way the collection stores it.
|
|
8
|
+
*/
|
|
9
|
+
export const idFilter = (resource, id) => criteriaToFilter({ id }, new Map(resource.references().map(ref => [ref.field, ref])));
|
|
10
|
+
/** A unique index refused the write. */
|
|
11
|
+
export const isDuplicateKey = (error) => error?.code === MONGO_DUPLICATE_KEY;
|
|
12
|
+
//# sourceMappingURL=native.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"native.js","sourceRoot":"","sources":["../src/native.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAKhF;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,CAA2B,QAA0B,EAAE,EAAU,EAAY,EAAE,CACrG,gBAAgB,CAAC,EAAE,EAAE,EAAE,EAAE,IAAI,GAAG,CAAC,QAAQ,CAAC,UAAU,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,CAAA;AAEvF,wCAAwC;AACxC,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,KAAc,EAAW,EAAE,CACvD,KAAmC,EAAE,IAAI,KAAK,mBAAmB,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/server-auth-identity",
|
|
3
|
-
"version": "0.1.18-rc.
|
|
3
|
+
"version": "0.1.18-rc.50",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -23,12 +23,12 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@owlmeans/auth": "^0.1.18-rc.41",
|
|
26
|
-
"@owlmeans/auth-common": "^0.1.18-rc.
|
|
27
|
-
"@owlmeans/basic-ids": "^0.1.18-rc.
|
|
26
|
+
"@owlmeans/auth-common": "^0.1.18-rc.50",
|
|
27
|
+
"@owlmeans/basic-ids": "^0.1.18-rc.40",
|
|
28
28
|
"@owlmeans/context": "^0.1.18-rc.34",
|
|
29
29
|
"@owlmeans/log": "^0.1.18-rc.3",
|
|
30
30
|
"@owlmeans/mongo-resource": "^0.1.18-rc.44",
|
|
31
|
-
"@owlmeans/oidc": "^0.1.18-rc.
|
|
31
|
+
"@owlmeans/oidc": "^0.1.18-rc.58",
|
|
32
32
|
"@owlmeans/resource": "^0.1.18-rc.40",
|
|
33
33
|
"@owlmeans/server-context": "^0.1.18-rc.44",
|
|
34
34
|
"@scure/base": "^2.4.0"
|
package/src/consts.local.ts
CHANGED
|
@@ -7,3 +7,9 @@
|
|
|
7
7
|
* resolving through `formerSlugs` rather than failing.
|
|
8
8
|
*/
|
|
9
9
|
export const CACHE_TTL = 30_000
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The fields of an organization group a field-level update may write. `service` and `key` are the
|
|
13
|
+
* group's identity and are never rewritten in place.
|
|
14
|
+
*/
|
|
15
|
+
export const ORG_GROUP_FIELDS = ['title', 'managed', 'permissions', 'bundles'] as const
|
package/src/groups/types.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { OrgGroup } from '../types.js'
|
|
2
2
|
|
|
3
|
+
/** What `updateOrgGroup` may write — each field present is set, each absent one is left alone. */
|
|
4
|
+
export interface OrgGroupFields extends Partial<Pick<OrgGroup, 'title' | 'managed' | 'permissions' | 'bundles'>> {}
|
|
5
|
+
|
|
3
6
|
/** The groups apps keep in organizations of one context's identity store. */
|
|
4
7
|
export interface OrgGroupHelper {
|
|
5
8
|
/** The groups one app keeps in an organization. */
|
|
@@ -15,6 +18,16 @@ export interface OrgGroupHelper {
|
|
|
15
18
|
* @throws {UnknownRecordError} when the organization does not exist.
|
|
16
19
|
*/
|
|
17
20
|
putOrgGroup: (entityId: string, group: OrgGroup) => Promise<OrgGroup>
|
|
21
|
+
/**
|
|
22
|
+
* Set only the given fields (`title`, `managed`, `permissions`, `bundles`) of the EXISTING group
|
|
23
|
+
* (`service`, `key`) — `true` when it matched; it never creates one.
|
|
24
|
+
*
|
|
25
|
+
* Two writers of one group that own different fields — a platform sync keeping `managed` and
|
|
26
|
+
* `bundles`, an operator granting into `permissions` — must not undo each other, and
|
|
27
|
+
* `putOrgGroup` replaces the whole element from whatever its caller read. Use `putOrgGroup` only
|
|
28
|
+
* to create a group; change an existing one through this.
|
|
29
|
+
*/
|
|
30
|
+
updateOrgGroup: (entityId: string, service: string, key: string, fields: OrgGroupFields) => Promise<boolean>
|
|
18
31
|
/**
|
|
19
32
|
* Remove the group (`service`, `key`) from an organization — `true` when it was there. The rows that
|
|
20
33
|
* name it in their `groups` are the caller's to update.
|
package/src/groups.ts
CHANGED
|
@@ -4,8 +4,9 @@ import type { Document } from 'mongodb'
|
|
|
4
4
|
import type { OrgEntityResource, OrgGroup } from './types.js'
|
|
5
5
|
import { AUTH_IDENTITY_ORG_ENTITY, MAX_GUARDED_UPDATE_ATTEMPTS } from './consts.js'
|
|
6
6
|
import { nativeUtils } from './utils/native.js'
|
|
7
|
-
import type { OrgGroupHelper } from './groups/types.js'
|
|
7
|
+
import type { OrgGroupFields, OrgGroupHelper } from './groups/types.js'
|
|
8
8
|
import type { Context } from './types.local.js'
|
|
9
|
+
import { ORG_GROUP_FIELDS } from './consts.local.js'
|
|
9
10
|
|
|
10
11
|
export const makeOrgGroupHelper = (ctx: Context): OrgGroupHelper => {
|
|
11
12
|
const entities = () => ctx.resource<OrgEntityResource>(AUTH_IDENTITY_ORG_ENTITY)
|
|
@@ -42,6 +43,20 @@ export const makeOrgGroupHelper = (ctx: Context): OrgGroupHelper => {
|
|
|
42
43
|
throw new SyntaxError(`entity:group-contended:${group.service}:${group.key}`)
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
const updateOrgGroup = async (entityId: string, service: string, key: string, fields: OrgGroupFields): Promise<boolean> => {
|
|
47
|
+
const res = entities()
|
|
48
|
+
// Positional paths of the matched element only: the other fields of the group, and every
|
|
49
|
+
// other group of the organization, stay exactly as the store has them now.
|
|
50
|
+
const set: Document = Object.fromEntries(ORG_GROUP_FIELDS.filter(field => fields[field] !== undefined)
|
|
51
|
+
.map(field => [`groups.$.${field}`, fields[field]]))
|
|
52
|
+
const result = await res.collection.updateOne(
|
|
53
|
+
{ ...nativeUtils.idFilter(res, entityId), groups: { $elemMatch: { service, key } } },
|
|
54
|
+
{ $set: { ...set, updatedAt: new Date() } },
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return result.matchedCount > 0
|
|
58
|
+
}
|
|
59
|
+
|
|
45
60
|
const removeOrgGroup = async (entityId: string, service: string, key: string): Promise<boolean> => {
|
|
46
61
|
const res = entities()
|
|
47
62
|
const pull: Document = { $pull: { groups: { service, key } }, $set: { updatedAt: new Date() } }
|
|
@@ -52,7 +67,7 @@ export const makeOrgGroupHelper = (ctx: Context): OrgGroupHelper => {
|
|
|
52
67
|
return result.matchedCount > 0
|
|
53
68
|
}
|
|
54
69
|
|
|
55
|
-
return { listOrgGroups, putOrgGroup, removeOrgGroup }
|
|
70
|
+
return { listOrgGroups, putOrgGroup, updateOrgGroup, removeOrgGroup }
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
/** The organization groups of a context — one per context. */
|
package/tests/context.ts
CHANGED
|
@@ -56,14 +56,22 @@ const setPath = (doc: Rec, path: string, value: unknown): void => {
|
|
|
56
56
|
target[steps[steps.length - 1]!] = value
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* `$set` (dotted, and the positional `array.$` / `array.$.field` of the element the filter's
|
|
61
|
+
* `$elemMatch` selects), `$push`, `$pull`.
|
|
62
|
+
*/
|
|
60
63
|
const applyUpdate = (doc: Rec, update: Rec, filter: Rec): void => {
|
|
61
64
|
for (const [path, value] of Object.entries(update.$set ?? {})) {
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
const positional = path.match(/^([^$]+)\.\$(?:\.(.+))?$/)
|
|
66
|
+
if (positional != null) {
|
|
67
|
+
const [, field, rest] = positional as [string, string, string | undefined]
|
|
64
68
|
const items = doc[field] as Rec[]
|
|
65
69
|
const at = items.findIndex(item => recordQueryHelper.matchCriteria(item, filter[field].$elemMatch))
|
|
66
|
-
|
|
70
|
+
if (rest == null) {
|
|
71
|
+
items[at] = structuredClone(value) as Rec
|
|
72
|
+
} else {
|
|
73
|
+
setPath(items[at]!, rest, structuredClone(value))
|
|
74
|
+
}
|
|
67
75
|
continue
|
|
68
76
|
}
|
|
69
77
|
setPath(doc, path, structuredClone(value))
|
package/tests/resolver.spec.ts
CHANGED
|
@@ -99,6 +99,43 @@ describe('organization groups', () => {
|
|
|
99
99
|
expect(row().groups.filter((item: OrgGroup) => item.service === 'shop-taskly' && item.key === 'members')).toHaveLength(1)
|
|
100
100
|
})
|
|
101
101
|
|
|
102
|
+
test('updateOrgGroup sets only the given fields of the matched group, and never creates one', async () => {
|
|
103
|
+
const { ctx, entityId, row } = await organization()
|
|
104
|
+
const grants = [{ scope: 'shop-taskly', permissions: { 'article--read': false } }]
|
|
105
|
+
await orgGroupsOf(ctx).putOrgGroup(entityId, { ...group('shop-taskly', 'members', 'Staff'), permissions: grants })
|
|
106
|
+
await orgGroupsOf(ctx).putOrgGroup(entityId, group('shop-other', 'members', 'Other'))
|
|
107
|
+
|
|
108
|
+
expect(await orgGroupsOf(ctx).updateOrgGroup(entityId, 'shop-taskly', 'members', {
|
|
109
|
+
managed: true, bundles: [{ permissions: ['article--read'] }],
|
|
110
|
+
// Identity fields are not the update's to write, whatever a caller passes.
|
|
111
|
+
...({ key: 'renamed', service: 'shop-other' } as {}),
|
|
112
|
+
})).toBe(true)
|
|
113
|
+
|
|
114
|
+
expect(row().groups).toEqual([
|
|
115
|
+
{ service: 'shop-taskly', key: 'members', title: 'Staff', managed: true, permissions: grants, bundles: [{ permissions: ['article--read'] }] },
|
|
116
|
+
group('shop-other', 'members', 'Other'),
|
|
117
|
+
])
|
|
118
|
+
expect(await orgGroupsOf(ctx).updateOrgGroup(entityId, 'shop-taskly', 'editors', { title: 'Editors' })).toBe(false)
|
|
119
|
+
expect(row().groups).toHaveLength(2)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('concurrent field-level writers of one group keep each other\'s fields', async () => {
|
|
123
|
+
const { ctx, entityId, row } = await organization()
|
|
124
|
+
await orgGroupsOf(ctx).putOrgGroup(entityId, group('shop-taskly', 'members'))
|
|
125
|
+
const grants = [{ scope: 'shop-taskly', permissions: { 'article--edit': true } }]
|
|
126
|
+
|
|
127
|
+
await Promise.all([
|
|
128
|
+
orgGroupsOf(ctx).updateOrgGroup(entityId, 'shop-taskly', 'members', { managed: true, bundles: [{ permissions: ['article--read'] }] }),
|
|
129
|
+
orgGroupsOf(ctx).updateOrgGroup(entityId, 'shop-taskly', 'members', { permissions: grants }),
|
|
130
|
+
orgGroupsOf(ctx).updateOrgGroup(entityId, 'shop-taskly', 'members', { title: 'Staff' }),
|
|
131
|
+
])
|
|
132
|
+
|
|
133
|
+
expect(row().groups).toEqual([{
|
|
134
|
+
service: 'shop-taskly', key: 'members', title: 'Staff', managed: true, permissions: grants,
|
|
135
|
+
bundles: [{ permissions: ['article--read'] }],
|
|
136
|
+
}])
|
|
137
|
+
})
|
|
138
|
+
|
|
102
139
|
test('remove pulls only that group; an unknown organization is refused', async () => {
|
|
103
140
|
const { ctx, entityId, row } = await organization()
|
|
104
141
|
await orgGroupsOf(ctx).putOrgGroup(entityId, group('shop-taskly', 'members'))
|