@astrale-domains/shell 0.1.0-beta.48 → 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.
- package/README.md +56 -9
- package/dist/domain.d.ts +37 -0
- package/dist/domain.js +6 -0
- package/dist/functions/user/index.js +1 -0
- package/dist/functions/user/record-profile.js +18 -0
- package/dist/functions/view-access/assign-views.js +86 -0
- package/dist/functions/view-access/index.js +1 -0
- package/dist/functions/view-access/view-access-errors.js +28 -0
- package/dist/mutations/user/index.js +1 -0
- package/dist/mutations/user/record-user-profile.js +13 -0
- package/dist/mutations/view-access/index.js +1 -0
- package/dist/mutations/view-access/set-view-access.js +29 -0
- package/dist/queries/user/index.js +1 -0
- package/dist/queries/user/read-user-profile.js +24 -0
- package/dist/queries/view-access/index.js +1 -0
- package/dist/queries/view-access/read-view-access.js +35 -0
- package/dist/runtime.d.ts +655 -1
- package/dist/runtime.js +4 -1
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.js +1 -0
- package/dist/schema/modules/user/classes/user.d.ts +9 -0
- package/dist/schema/modules/user/classes/user.js +9 -3
- package/dist/schema/modules/user/functions/index.js +1 -0
- package/dist/schema/modules/user/functions/record-profile.js +14 -0
- package/dist/schema/modules/user/types/index.d.ts +1 -0
- package/dist/schema/modules/user/types/index.js +1 -0
- package/dist/schema/modules/user/types/user-profile.d.ts +7 -0
- package/dist/schema/modules/user/types/user-profile.js +7 -0
- package/dist/schema/modules/view-access/classes/index.d.ts +1 -0
- package/dist/schema/modules/view-access/classes/index.js +1 -0
- package/dist/schema/modules/view-access/classes/view-access.d.ts +22 -0
- package/dist/schema/modules/view-access/classes/view-access.js +18 -0
- package/dist/schema/modules/view-access/core/index.d.ts +1 -0
- package/dist/schema/modules/view-access/core/index.js +1 -0
- package/dist/schema/modules/view-access/core/view-only-group.d.ts +5 -0
- package/dist/schema/modules/view-access/core/view-only-group.js +10 -0
- package/dist/schema/modules/view-access/functions/assign-views.d.ts +16 -0
- package/dist/schema/modules/view-access/functions/assign-views.js +17 -0
- package/dist/schema/modules/view-access/functions/index.d.ts +1 -0
- package/dist/schema/modules/view-access/functions/index.js +1 -0
- package/dist/schema/modules/view-access/index.d.ts +3 -0
- package/dist/schema/modules/view-access/index.js +3 -0
- package/dist/schema/modules/view-access/types/index.js +1 -0
- package/dist/schema/modules/view-access/types/view-assignment.js +16 -0
- package/dist/schema/policies/index.js +1 -0
- package/dist/schema/policies/is-self.js +5 -0
- package/dist/schema/schema.d.ts +136 -0
- package/dist/schema/schema.js +6 -4
- package/package.json +3 -3
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
|
|
5
|
-
Core Namespace, three static methods, three Namespace methods,
|
|
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
|
|
146
|
-
can call those methods. Core member receives Download, Introspect, Query,
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
{
|
|
@@ -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
|
+
}));
|