@astrale-domains/shell 0.1.0-beta.49 → 0.1.0-beta.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.
Files changed (49) hide show
  1. package/README.md +56 -9
  2. package/dist/domain.d.ts +37 -0
  3. package/dist/domain.js +6 -0
  4. package/dist/functions/user/index.js +1 -0
  5. package/dist/functions/user/record-profile.js +18 -0
  6. package/dist/functions/view-access/assign-views.js +86 -0
  7. package/dist/functions/view-access/index.js +1 -0
  8. package/dist/functions/view-access/view-access-errors.js +28 -0
  9. package/dist/mutations/user/index.js +1 -0
  10. package/dist/mutations/user/record-user-profile.js +13 -0
  11. package/dist/mutations/view-access/index.js +1 -0
  12. package/dist/mutations/view-access/set-view-access.js +29 -0
  13. package/dist/queries/user/index.js +1 -0
  14. package/dist/queries/user/read-user-profile.js +24 -0
  15. package/dist/queries/view-access/index.js +1 -0
  16. package/dist/queries/view-access/read-view-access.js +35 -0
  17. package/dist/runtime.d.ts +655 -1
  18. package/dist/runtime.js +4 -1
  19. package/dist/schema/index.d.ts +1 -0
  20. package/dist/schema/index.js +1 -0
  21. package/dist/schema/modules/user/classes/user.d.ts +9 -0
  22. package/dist/schema/modules/user/classes/user.js +9 -3
  23. package/dist/schema/modules/user/functions/index.js +1 -0
  24. package/dist/schema/modules/user/functions/record-profile.js +14 -0
  25. package/dist/schema/modules/user/types/index.d.ts +1 -0
  26. package/dist/schema/modules/user/types/index.js +1 -0
  27. package/dist/schema/modules/user/types/user-profile.d.ts +7 -0
  28. package/dist/schema/modules/user/types/user-profile.js +7 -0
  29. package/dist/schema/modules/view-access/classes/index.d.ts +1 -0
  30. package/dist/schema/modules/view-access/classes/index.js +1 -0
  31. package/dist/schema/modules/view-access/classes/view-access.d.ts +22 -0
  32. package/dist/schema/modules/view-access/classes/view-access.js +18 -0
  33. package/dist/schema/modules/view-access/core/index.d.ts +1 -0
  34. package/dist/schema/modules/view-access/core/index.js +1 -0
  35. package/dist/schema/modules/view-access/core/view-only-group.d.ts +5 -0
  36. package/dist/schema/modules/view-access/core/view-only-group.js +10 -0
  37. package/dist/schema/modules/view-access/functions/assign-views.d.ts +16 -0
  38. package/dist/schema/modules/view-access/functions/assign-views.js +17 -0
  39. package/dist/schema/modules/view-access/functions/index.d.ts +1 -0
  40. package/dist/schema/modules/view-access/functions/index.js +1 -0
  41. package/dist/schema/modules/view-access/index.d.ts +3 -0
  42. package/dist/schema/modules/view-access/index.js +3 -0
  43. package/dist/schema/modules/view-access/types/index.js +1 -0
  44. package/dist/schema/modules/view-access/types/view-assignment.js +16 -0
  45. package/dist/schema/policies/index.js +1 -0
  46. package/dist/schema/policies/is-self.js +5 -0
  47. package/dist/schema/schema.d.ts +136 -0
  48. package/dist/schema/schema.js +6 -4
  49. package/package.json +2 -2
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Shell Domain
2
2
 
3
3
  Shell owns two concrete Identity classes, User and Group, the member_of_group relation,
4
- the admin/member/applications Core groups, the Namespace class and its alias relation, the `views`
5
- Core Namespace, three static methods, three Namespace methods, one Domain function, and one
4
+ the admin/member/applications/view-only Core groups, the Namespace class and its alias relation, the
5
+ view_access relation, the `views` Core Namespace, three static methods, one User method, three Namespace methods, two Domain functions, and one
6
6
  application View, which is its entrypoint. The Schema names the Domain `Shell`. There is no Space, setup function, personal home, or public repair method.
7
7
 
8
8
  ## Contracts
@@ -13,6 +13,17 @@ application View, which is its entrypoint. The Schema names the Domain `Shell`.
13
13
  | Group.assignMember | `{ member: UserPath \| GroupPath, group: GroupPath }` | `{}` |
14
14
  | Group.unassignMember | Same | `{}` |
15
15
 
16
+ | User method | Input | Output |
17
+ | --- | --- | --- |
18
+ | User.recordProfile | `{ email?: string, displayName?: string }` | `{}` |
19
+
20
+ An account records the email and display name it signs in with, so the directory can name it, in
21
+ `claimedEmail` and `claimedName`: named apart from the `email` or `displayName` a Domain extending
22
+ User declares for its own profile. Its
23
+ `isSelf` Policy admits the account itself alone, and Shell writes, with its own authority, only what
24
+ changed; an email is kept in lower case. The values come from the account, not from its identity
25
+ provider: they name it for display and decide nothing. The GUI records them at each sign-in.
26
+
16
27
  | Namespace method | Input | Output |
17
28
  | --- | --- | --- |
18
29
  | Namespace.link | `{ name: string, target: NodePath }` | `{}` |
@@ -22,6 +33,7 @@ application View, which is its entrypoint. The Schema names the Domain `Shell`.
22
33
  | Domain function | Input | Output |
23
34
  | --- | --- | --- |
24
35
  | listEntrypoints | `{}` | `{ entrypoints: { origin, name, view }[] }` |
36
+ | assignViews | `{ assignments: { identity, set?, unset? }[] }` | `{}` |
25
37
 
26
38
  listEntrypoints reads, with Shell's own authority, every Kernel `of_domain` locator whose View has
27
39
  an outgoing `entrypoint` Edge, and returns one entry per Domain in origin order: its origin, the
@@ -103,6 +115,37 @@ Every other failure propagates. Core admin holds the three methods, `read` and `
103
115
  Namespace, and every operation on alias. Administration follows `administersShellClass` on the
104
116
  Namespace, as for Groups.
105
117
 
118
+ ## View access
119
+
120
+ **A View given to an account decides where it lands, never what it reads.** `view_access` runs from
121
+ a User or a Group to one Kernel View. The Core group `view-only` limits its members to the Views
122
+ they hold, directly or through a group they are a direct member of; membership in `view-only` and
123
+ in Core `admin` counts the same way. Everyone else, and every administrator, opens the workspace as
124
+ before. Nothing else changes: the View's own Domain still
125
+ guards every Node it shows, and `View.resolve` and `listEntrypoints` answer as before.
126
+
127
+ | Fact | Who writes it | How |
128
+ | --- | --- | --- |
129
+ | `view_access(holder → View)` | Core admin | `assignViews`, or a Mutation that creates or deletes the Edge. It carries no property. |
130
+ | `member_of_group(user → view-only)` | Core admin | `Group.assignMember` / `Group.unassignMember`. |
131
+
132
+ `view_access` declares `read: true` and `traverse: true`, as `alias` does. Its target is a
133
+ Class Shell does not own, so it detaches when its View is deleted, by a Mutation or by a release
134
+ that removes the View. Clients read a visitor's access with its own Queries
135
+ (`shell.views.access()`): never a read function. A Shell release without `view-only` limits no one.
136
+
137
+ `assignViews` takes, for each identity (a User or a Group, by `@NodeId`), the Views to give
138
+ (`set`) and to withdraw (`unset`), by `@NodeId`. It reads the identities' `view_access` Edges and
139
+ writes only what differs, in one Mutation guarded by that read, with Shell's own authority: a View
140
+ already given or already withdrawn costs nothing, and a concurrent change refuses the whole write
141
+ (`VIEW_ACCESS_CONFLICT`). An identity listed twice gets both lists; one View both given and
142
+ withdrawn is refused. One call changes at most 40 identities and 20 Views per list. Core admin
143
+ holds it, and may share `can_use` on it. It never changes `view-only`: whether an identity opens the workspace stays its
144
+ membership, which `Group.assignMember` changes.
145
+
146
+ To limit an invited person to one View: invite them, assign them to `view-only`, give them the
147
+ View with `assignViews`, and give the View an alias in `views` so `/v/<name>` opens it.
148
+
106
149
  ## Flows
107
150
 
108
151
  ```text
@@ -142,18 +185,22 @@ Kernel E2E evidence, not a handler test, qualifies this behavior.
142
185
 
143
186
  The Shell principal requests all Kernel root functions, the required extends_with operations,
144
187
  and the declared grant/revoke authority. Core admin receives the full declared Shell profile,
145
- including invite, assignMember, unassignMember, link, unlink and rename so humans inheriting admin
146
- can call those methods. Core member receives Download, Introspect, Query, Mutate, View.resolve and
147
- listEntrypoints, not blanket class-read grants. Core applications receives listEntrypoints,
188
+ including invite, recordProfile, assignMember, unassignMember, link, unlink, rename and assignViews
189
+ so humans inheriting admin can call those methods. Core member receives Download, Introspect, Query,
190
+ Mutate, View.resolve, listEntrypoints and recordProfile, not blanket class-read grants. Core applications receives listEntrypoints,
148
191
  View.resolve and Query.
149
192
  The Domain definition's requirements, including Core Identity capabilities in requirements.core, are owned by domain.ts and materialized by installation,
150
193
  never by invitation handlers.
151
194
 
152
195
  The sole application entrypoint uses the Vite React frontend at /, composed with
153
- `@astrale-os/shell-react` and `@astrale-os/ui`. It lists visible User/Group families in two tabs
154
- under the caller's session, with loading/empty/error states and independent refresh.
155
- Users display their concrete class and Node ID, not a guessed foreign profile.
156
- It does not proxy privileged directory reads or enumerate installed applications.
196
+ `@astrale-os/shell-react` and `@astrale-os/ui`. It administers the instance under the caller's own
197
+ session, in two tabs. **Access** lists users, then groups: whether each opens the whole OS or only its Views (two spelled-out options
198
+ over its `view-only` membership; an administrator always does, a Core group is decided by its role)
199
+ and, through a picker, the installed Views given to it. **Groups** lists the groups and the direct
200
+ members of the chosen one, to add or remove. Every change applies at once and comes back with a
201
+ message when the Kernel refuses it. A user shows its recorded display name and email, or its concrete
202
+ class when it recorded none, never a guessed foreign profile. Every read is a Query under the
203
+ caller's rights; the screen proxies no privileged read.
157
204
 
158
205
  ## Source and qualification
159
206
 
package/dist/domain.d.ts CHANGED
@@ -14,6 +14,8 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
14
14
  readonly extends: readonly [import("@astrale-os/sdk/schema").KernelClass<"Identity">];
15
15
  readonly properties: {
16
16
  readonly creationKey: import("zod").ZodOptional<import("zod").ZodString>;
17
+ readonly claimedEmail: import("zod").ZodOptional<import("zod").ZodString>;
18
+ readonly claimedName: import("zod").ZodOptional<import("zod").ZodString>;
17
19
  };
18
20
  readonly methods: {
19
21
  readonly invite: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
@@ -27,6 +29,13 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
27
29
  state: "accepted" | "cancelled" | "completed" | "expired" | "failed" | "pending" | "registered" | "registering";
28
30
  };
29
31
  }, "authorized", true, false, undefined, undefined>>;
32
+ readonly recordProfile: import("@astrale-os/sdk/schema").Method<import("@astrale-os/sdk/schema").CallableProjection<{
33
+ email?: string | undefined;
34
+ displayName?: string | undefined;
35
+ }, {
36
+ readonly mode: 'value';
37
+ readonly value: Record<string, never>;
38
+ }, "authorized", false, false, undefined, undefined>>;
30
39
  };
31
40
  }>;
32
41
  readonly Group: import("@astrale-os/sdk/schema").AuthoredNodeClass<{
@@ -115,6 +124,22 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
115
124
  readonly constraints: Record<never, never>;
116
125
  readonly effectiveProperties: never;
117
126
  }>;
127
+ readonly view_access: import("@astrale-os/sdk/schema").DirectedEdgeClass<{
128
+ readonly depth: 0;
129
+ readonly abstract: false;
130
+ readonly extends: readonly [];
131
+ readonly properties: never;
132
+ readonly source: {
133
+ readonly as: "holder";
134
+ readonly outgoing: "0..*";
135
+ };
136
+ readonly target: {
137
+ readonly as: "view";
138
+ readonly incoming: "0..*";
139
+ };
140
+ readonly constraints: Record<never, never>;
141
+ readonly effectiveProperties: never;
142
+ }>;
118
143
  };
119
144
  readonly functions: {
120
145
  readonly listEntrypoints: import("@astrale-os/sdk/schema").Function<import("@astrale-os/sdk/schema").CallableProjection<Record<string, never>, {
@@ -127,12 +152,23 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
127
152
  }[];
128
153
  };
129
154
  }, "authorized", false, false, undefined, undefined>>;
155
+ readonly assignViews: import("@astrale-os/sdk/schema").Function<import("@astrale-os/sdk/schema").CallableProjection<{
156
+ assignments: {
157
+ identity: string;
158
+ set?: string[] | undefined;
159
+ unset?: string[] | undefined;
160
+ }[];
161
+ }, {
162
+ readonly mode: 'value';
163
+ readonly value: Record<string, never>;
164
+ }, "authorized", false, false, undefined, undefined>>;
130
165
  };
131
166
  readonly policies: {
132
167
  readonly belongsToGroup: import("@astrale-os/sdk/schema").Policy;
133
168
  readonly ownsClass: import("@astrale-os/sdk/schema").Policy;
134
169
  readonly administersShellClass: import("@astrale-os/sdk/schema").Policy;
135
170
  readonly usesFunction: import("@astrale-os/sdk/schema").Policy;
171
+ readonly isSelf: import("@astrale-os/sdk/schema").Policy;
136
172
  };
137
173
  readonly views: {
138
174
  readonly application: import("@astrale-os/sdk/schema").View;
@@ -143,6 +179,7 @@ declare const frontend: import("@astrale-os/sdk/view").Frontend<import("@astrale
143
179
  readonly applications: import("@astrale-os/sdk/schema").CoreNode;
144
180
  readonly member: import("@astrale-os/sdk/schema").CoreNode;
145
181
  readonly views: import("@astrale-os/sdk/schema").CoreNode;
182
+ readonly 'view-only': import("@astrale-os/sdk/schema").CoreNode;
146
183
  };
147
184
  };
148
185
  }, {
package/dist/domain.js CHANGED
@@ -29,6 +29,7 @@ const domainRequirements = requirements({
29
29
  functions: [
30
30
  ...KernelRootFunctions,
31
31
  S.classes.User.static.methods.invite,
32
+ S.classes.User.methods.recordProfile,
32
33
  S.classes.Group.static.methods.assignMember,
33
34
  S.classes.Group.static.methods.unassignMember,
34
35
  S.classes.Namespace.methods.link,
@@ -36,6 +37,7 @@ const domainRequirements = requirements({
36
37
  S.classes.Namespace.methods.rename,
37
38
  // Core Edges cannot author Kernel extends_with, so administrators hold it directly.
38
39
  S.functions.listEntrypoints,
40
+ S.functions.assignViews,
39
41
  ],
40
42
  classes: [
41
43
  { class: S.classes.User, operations: ['create', 'read', 'traverse', 'update', 'delete'] },
@@ -49,6 +51,8 @@ const domainRequirements = requirements({
49
51
  },
50
52
  { class: S.classes.Namespace, operations: ['read', 'traverse'] },
51
53
  { class: S.classes.alias, operations: ['create', 'read', 'traverse', 'update', 'delete'] },
54
+ // assignViews gives and withdraws Views; a Mutation may too. view_access carries no property.
55
+ { class: S.classes.view_access, operations: ['create', 'read', 'traverse', 'delete'] },
52
56
  { class: K.classes.extends_with, operations: ['create', 'read', 'traverse', 'delete'] },
53
57
  ],
54
58
  authority: {
@@ -67,6 +71,8 @@ const domainRequirements = requirements({
67
71
  K.classes.View.static.methods.resolve,
68
72
  // The workspace opens only on this catalogue: an invited member must be able to read it.
69
73
  S.functions.listEntrypoints,
74
+ // The GUI records the email and name each account signs in with; isSelf bounds it.
75
+ S.classes.User.methods.recordProfile,
70
76
  ],
71
77
  },
72
78
  {
@@ -1 +1,2 @@
1
1
  export { invite } from './invite-user.js';
2
+ export { recordProfile } from './record-profile.js';
@@ -0,0 +1,18 @@
1
+ import { defineAction } from '@astrale-os/sdk/action';
2
+ import { recordUserProfile } from '../../mutations/user/index.js';
3
+ import { readUserProfile } from '../../queries/user/index.js';
4
+ /**
5
+ * One read and, only when something changed, one write, with Shell's own authority: the policy
6
+ * admits the account itself alone. An email is kept in lower case.
7
+ */
8
+ export const recordProfile = defineAction()('User.recordProfile', async ({ input, self, query, mutate }) => {
9
+ const asked = {
10
+ ...(input.email === undefined ? {} : { email: input.email.trim().toLowerCase() }),
11
+ ...(input.displayName === undefined ? {} : { displayName: input.displayName }),
12
+ };
13
+ const current = await query(readUserProfile, { user: self });
14
+ const set = Object.fromEntries(Object.entries(asked).filter(([key, value]) => current[key] !== value));
15
+ if (Object.keys(set).length > 0)
16
+ await mutate(recordUserProfile, { user: self, set });
17
+ return {};
18
+ });
@@ -0,0 +1,86 @@
1
+ import { defineAction } from '@astrale-os/sdk/action';
2
+ import { Path } from '@astrale-os/sdk/graph/path';
3
+ import { setViewAccess } from '../../mutations/view-access/index.js';
4
+ import { readViewAccess } from '../../queries/view-access/index.js';
5
+ import { canonicalNodeId } from '../../utils/canonical-node-path/index.js';
6
+ import { viewAccessErrors } from './view-access-errors.js';
7
+ /**
8
+ * One read and at most one Mutation, both with Shell's own authority: holding `can_use` on this
9
+ * Function is what admits the caller, Core admin by default. The write changes only what differs
10
+ * from the Edges read, under their guard, so a View already given or already withdrawn costs
11
+ * nothing and a concurrent change refuses the whole write. An identity listed twice gets both
12
+ * lists; one listed with no View is not read.
13
+ */
14
+ export const assignViews = defineAction()('assignViews', { errors: viewAccessErrors }, async ({ error, input, query, mutate }) => {
15
+ const nodeId = (reference) => {
16
+ const invalid = () => error('INVALID_VIEW_ASSIGNMENT_REFERENCE', {});
17
+ try {
18
+ return canonicalNodeId(reference, invalid);
19
+ }
20
+ catch {
21
+ throw invalid();
22
+ }
23
+ };
24
+ const wanted = new Map();
25
+ for (const assignment of input.assignments) {
26
+ const identity = nodeId(assignment.identity);
27
+ const lists = [
28
+ [assignment.set ?? [], true],
29
+ [assignment.unset ?? [], false],
30
+ ];
31
+ for (const [list, given] of lists) {
32
+ for (const reference of list) {
33
+ const view = nodeId(reference);
34
+ const views = wanted.get(identity) ?? new Map();
35
+ wanted.set(identity, views);
36
+ if (views.get(view) === !given) {
37
+ throw error('CONTRADICTORY_VIEW_ASSIGNMENT', {
38
+ identity: Path.id(identity).raw,
39
+ view: Path.id(view).raw,
40
+ });
41
+ }
42
+ views.set(view, given);
43
+ }
44
+ }
45
+ }
46
+ const [first, ...others] = wanted.keys();
47
+ if (first === undefined)
48
+ return {};
49
+ const read = () => query(readViewAccess, { identities: [first, ...others] });
50
+ const current = await read();
51
+ const { give, withdraw } = changes(wanted, current);
52
+ if (give.length === 0 && withdraw.length === 0)
53
+ return {};
54
+ const result = await mutate(setViewAccess, { give, withdraw, guard: current.guard });
55
+ if (result.ok)
56
+ return {};
57
+ switch (result.refusal) {
58
+ case 'ENDPOINT_NOT_FOUND':
59
+ // Every endpoint of a withdrawal was read: one that vanished was deleted meanwhile.
60
+ if (give.length > 0)
61
+ throw error('VIEW_ASSIGNMENT_ENDPOINT_NOT_FOUND', {});
62
+ throw error('VIEW_ACCESS_CONFLICT', {});
63
+ case 'CHANGED':
64
+ throw error('VIEW_ACCESS_CONFLICT', {});
65
+ case 'UNKNOWN': {
66
+ const after = changes(wanted, await read());
67
+ if (after.give.length === 0 && after.withdraw.length === 0)
68
+ return {};
69
+ throw error('VIEW_ACCESS_UNCONFIRMED', {});
70
+ }
71
+ }
72
+ });
73
+ /** What differs between the Views wanted and the Views held. */
74
+ function changes(wanted, current) {
75
+ const give = [];
76
+ const withdraw = [];
77
+ for (const [identity, views] of wanted) {
78
+ const held = current.given.get(identity);
79
+ for (const [view, given] of views) {
80
+ if (given === (held?.has(view) ?? false))
81
+ continue;
82
+ (given ? give : withdraw).push({ identity, view });
83
+ }
84
+ }
85
+ return { give, withdraw };
86
+ }
@@ -0,0 +1 @@
1
+ export { assignViews } from './assign-views.js';
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ export const viewAccessErrors = {
3
+ INVALID_VIEW_ASSIGNMENT_REFERENCE: {
4
+ family: 'VALIDATION_ERROR',
5
+ message: 'Identities and Views must be exact canonical @NodeId paths.',
6
+ details: z.strictObject({}),
7
+ },
8
+ CONTRADICTORY_VIEW_ASSIGNMENT: {
9
+ family: 'VALIDATION_ERROR',
10
+ message: 'One View is both given to and withdrawn from the same identity.',
11
+ details: z.strictObject({ identity: z.string(), view: z.string() }),
12
+ },
13
+ VIEW_ASSIGNMENT_ENDPOINT_NOT_FOUND: {
14
+ family: 'NOT_FOUND',
15
+ message: 'An identity is not a User or a Group, or a View to give is not a View.',
16
+ details: z.strictObject({}),
17
+ },
18
+ VIEW_ACCESS_CONFLICT: {
19
+ family: 'CONFLICT',
20
+ message: 'The Views of these identities changed concurrently; retry the operation.',
21
+ details: z.strictObject({}),
22
+ },
23
+ VIEW_ACCESS_UNCONFIRMED: {
24
+ family: 'CONFLICT',
25
+ message: 'The Views asked for could not be confirmed; retry the operation.',
26
+ details: z.strictObject({}),
27
+ },
28
+ };
@@ -0,0 +1 @@
1
+ export { recordUserProfile } from './record-user-profile.js';
@@ -0,0 +1,13 @@
1
+ import { defineMutation } from '@astrale-os/sdk/mutation';
2
+ /** Set the email and display name an account recorded about itself. */
3
+ export const recordUserProfile = defineMutation()((domain) => ({
4
+ id: 'shell.user.profile.record',
5
+ build({ user, set }, mutation) {
6
+ const props = {
7
+ ...(set.email === undefined ? {} : { claimedEmail: set.email }),
8
+ ...(set.displayName === undefined ? {} : { claimedName: set.displayName }),
9
+ };
10
+ mutation.updateNode({ node: { id: user }, class: domain.classes.User, props: { set: props } });
11
+ },
12
+ project: () => ({}),
13
+ }));
@@ -0,0 +1 @@
1
+ export { setViewAccess } from './set-view-access.js';
@@ -0,0 +1,29 @@
1
+ import { defineMutation } from '@astrale-os/sdk/mutation';
2
+ /** Give and withdraw `view_access` Edges in one guarded write. */
3
+ export const setViewAccess = defineMutation()((domain) => ({
4
+ id: 'shell.view-access.set',
5
+ build(input, mutation) {
6
+ mutation.expect.query(input.guard);
7
+ const edge = ({ identity, view }) => ({
8
+ class: domain.classes.view_access,
9
+ source: identity,
10
+ target: view,
11
+ });
12
+ for (const access of input.give)
13
+ mutation.createEdge(edge(access));
14
+ for (const access of input.withdraw)
15
+ mutation.deleteEdge(edge(access));
16
+ },
17
+ project: () => ({ ok: true }),
18
+ reject: (failure) => {
19
+ if (failure.outcomeKnown) {
20
+ if (failure.code === 'MUTATION_PRECONDITION_FAILED' || failure.code === 'MUTATION_CONFLICT')
21
+ return { ok: false, refusal: 'CHANGED' };
22
+ // An endpoint reaches no Node of a Class view_access accepts.
23
+ if (failure.code === 'MUTATION_LOCATOR_INVALID')
24
+ return { ok: false, refusal: 'ENDPOINT_NOT_FOUND' };
25
+ throw failure;
26
+ }
27
+ return { ok: false, refusal: 'UNKNOWN' };
28
+ },
29
+ }));
@@ -0,0 +1 @@
1
+ export { readUserProfile } from './read-user-profile.js';
@@ -0,0 +1,24 @@
1
+ import { Path } from '@astrale-os/sdk/graph/path';
2
+ import { defineQuery, Query, queryResult, } from '@astrale-os/sdk/query';
3
+ /** The email and display name one account recorded; nothing for an account it cannot read. */
4
+ export const readUserProfile = defineQuery()((domain) => {
5
+ const { claimedEmail: email, claimedName: displayName } = domain.classes.User.properties;
6
+ return {
7
+ id: 'shell.user.profile',
8
+ page: { size: 1 },
9
+ pagination: { maximumPages: 1 },
10
+ build: ({ user }) => Query.from({ nodes: [Path.id(user)] }).select({
11
+ kind: 'nodes',
12
+ projection: { kind: 'value' },
13
+ }),
14
+ project(pages) {
15
+ const [node] = queryResult.completeNodeValues(pages, 'Shell user profile');
16
+ const text = (value) => (typeof value === 'string' ? value : undefined);
17
+ const recorded = {
18
+ email: text(node?.props[email.key]),
19
+ displayName: text(node?.props[displayName.key]),
20
+ };
21
+ return Object.fromEntries(Object.entries(recorded).filter(([, value]) => value !== undefined));
22
+ },
23
+ };
24
+ });
@@ -0,0 +1 @@
1
+ export { readViewAccess } from './read-view-access.js';
@@ -0,0 +1,35 @@
1
+ import { Path } from '@astrale-os/sdk/graph/path';
2
+ import { Query, defineQuery, queryResult, } from '@astrale-os/sdk/query';
3
+ /** Every `view_access` Edge from the identities, kept whole as a guard for the write it decides. */
4
+ export const readViewAccess = defineQuery()((domain) => ({
5
+ id: 'shell.view-access.read',
6
+ build({ identities }) {
7
+ const held = Query.from({
8
+ nodes: [Path.id(identities[0]), ...identities.slice(1).map((identity) => Path.id(identity))],
9
+ }).expand({
10
+ via: [domain.classes.view_access],
11
+ direction: 'outgoing',
12
+ });
13
+ return held.select({
14
+ kind: 'edges',
15
+ binding: held.edge,
16
+ projection: { edge: 'value', source: 'reference', target: 'reference' },
17
+ });
18
+ },
19
+ // A guard checks at most 4,096 Edges: 16 pages of 256.
20
+ pagination: { maximumPages: 16 },
21
+ project(pages, _input, { ast, complete }) {
22
+ const given = new Map();
23
+ for (const page of pages) {
24
+ for (const item of queryResult.edgeItems(page, 'Shell view access')) {
25
+ if (item.edge.kind !== 'value')
26
+ throw new TypeError('Expected an Edge value.');
27
+ const { source, target } = item.edge.value;
28
+ const views = given.get(source) ?? new Set();
29
+ views.add(target);
30
+ given.set(source, views);
31
+ }
32
+ }
33
+ return { given, guard: { query: ast, equals: complete } };
34
+ },
35
+ }));