@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 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.49
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.52
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
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/server-auth-identity",
4
- "version": "0.1.18-rc.49",
5
- "generatedAt": "2026-10-08T21:27:54.283Z",
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.49"` in `dependencies`
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
@@ -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"}
@@ -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"}
@@ -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"}
@@ -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,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAA;AACvD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAA;AAE/C,eAAO,MAAM,kBAAkB,QAAS,OAAO,KAAG,cA8CjD,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"}
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);
@@ -1 +1 @@
1
- {"version":3,"file":"groups.js","sourceRoot":"","sources":["../src/groups.ts"],"names":[],"mappings":"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"}
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
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=iam.js.map
@@ -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"}
@@ -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.49",
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.49",
27
- "@owlmeans/basic-ids": "^0.1.18-rc.39",
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.57",
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"
@@ -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
@@ -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
- /** `$set` (dotted, and the positional `array.$` the filter's `$elemMatch` selects), `$push`, `$pull`. */
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
- if (path.endsWith('.$')) {
63
- const field = path.slice(0, -2)
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
- items[at] = structuredClone(value) as Rec
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))
@@ -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'))