cursedbelt-core 1.0.0
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/LICENSE +15 -0
- package/dist/client/wsWire.d.ts +49 -0
- package/dist/client/wsWire.js +57 -0
- package/dist/config/appConfig.d.ts +361 -0
- package/dist/config/appConfig.js +308 -0
- package/dist/core/activity/index.d.ts +8 -0
- package/dist/core/activity/index.js +7 -0
- package/dist/core/activity/model.d.ts +115 -0
- package/dist/core/activity/model.js +63 -0
- package/dist/core/analytics/index.d.ts +18 -0
- package/dist/core/analytics/index.js +18 -0
- package/dist/core/analytics/range.d.ts +186 -0
- package/dist/core/analytics/range.js +271 -0
- package/dist/core/analytics/schedule.d.ts +301 -0
- package/dist/core/analytics/schedule.js +372 -0
- package/dist/core/category/index.d.ts +3 -0
- package/dist/core/category/index.js +6 -0
- package/dist/core/category/memoryStore.d.ts +21 -0
- package/dist/core/category/memoryStore.js +97 -0
- package/dist/core/category/tree.d.ts +26 -0
- package/dist/core/category/tree.js +113 -0
- package/dist/core/category/types.d.ts +48 -0
- package/dist/core/category/types.js +8 -0
- package/dist/core/color.d.ts +14 -0
- package/dist/core/color.js +32 -0
- package/dist/core/colorScheme/colorSchemeStore.d.ts +70 -0
- package/dist/core/colorScheme/colorSchemeStore.js +123 -0
- package/dist/core/colorScheme/index.d.ts +7 -0
- package/dist/core/colorScheme/index.js +7 -0
- package/dist/core/domainOwners.d.ts +19 -0
- package/dist/core/domainOwners.js +15 -0
- package/dist/core/download.d.ts +14 -0
- package/dist/core/download.js +37 -0
- package/dist/core/events/ccEventBus.d.ts +44 -0
- package/dist/core/events/ccEventBus.js +70 -0
- package/dist/core/file-tree/fileTreeModel.d.ts +276 -0
- package/dist/core/file-tree/fileTreeModel.js +312 -0
- package/dist/core/folder-tree/actions.d.ts +84 -0
- package/dist/core/folder-tree/actions.js +119 -0
- package/dist/core/folder-tree/index.d.ts +3 -0
- package/dist/core/folder-tree/index.js +20 -0
- package/dist/core/folder-tree/path.d.ts +76 -0
- package/dist/core/folder-tree/path.js +146 -0
- package/dist/core/folder-tree/tree.d.ts +86 -0
- package/dist/core/folder-tree/tree.js +184 -0
- package/dist/core/layout/index.d.ts +1 -0
- package/dist/core/layout/index.js +7 -0
- package/dist/core/master-lock/index.d.ts +14 -0
- package/dist/core/master-lock/index.js +14 -0
- package/dist/core/master-lock/kdf.d.ts +63 -0
- package/dist/core/master-lock/kdf.js +117 -0
- package/dist/core/master-lock/policy.d.ts +32 -0
- package/dist/core/master-lock/policy.js +52 -0
- package/dist/core/master-lock/presence.d.ts +52 -0
- package/dist/core/master-lock/presence.js +61 -0
- package/dist/core/master-lock/wire.d.ts +139 -0
- package/dist/core/master-lock/wire.js +81 -0
- package/dist/core/media/bgRemoval.d.ts +27 -0
- package/dist/core/media/bgRemoval.js +101 -0
- package/dist/core/media/ffmpeg.d.ts +57 -0
- package/dist/core/media/ffmpeg.js +126 -0
- package/dist/core/media/format.d.ts +20 -0
- package/dist/core/media/format.js +54 -0
- package/dist/core/media/index.d.ts +12 -0
- package/dist/core/media/index.js +18 -0
- package/dist/core/media/mediaItem.d.ts +15 -0
- package/dist/core/media/mediaItem.js +34 -0
- package/dist/core/media/renderPlan.d.ts +63 -0
- package/dist/core/media/renderPlan.js +232 -0
- package/dist/core/media/segment.d.ts +79 -0
- package/dist/core/media/segment.js +247 -0
- package/dist/core/media/size.d.ts +1 -0
- package/dist/core/media/size.js +3 -0
- package/dist/core/media/trim.d.ts +21 -0
- package/dist/core/media/trim.js +40 -0
- package/dist/core/media/types.d.ts +148 -0
- package/dist/core/media/types.js +11 -0
- package/dist/core/media/uploadLimits.d.ts +7 -0
- package/dist/core/media/uploadLimits.js +16 -0
- package/dist/core/media/validate.d.ts +9 -0
- package/dist/core/media/validate.js +121 -0
- package/dist/core/media/videoCuts.d.ts +152 -0
- package/dist/core/media/videoCuts.js +284 -0
- package/dist/core/navigation/breadcrumbCompat.d.ts +24 -0
- package/dist/core/navigation/breadcrumbCompat.js +21 -0
- package/dist/core/navigation/coverageTest.d.ts +24 -0
- package/dist/core/navigation/coverageTest.js +48 -0
- package/dist/core/navigation/index.d.ts +6 -0
- package/dist/core/navigation/index.js +10 -0
- package/dist/core/navigation/migrateNavPreferences.d.ts +55 -0
- package/dist/core/navigation/migrateNavPreferences.js +63 -0
- package/dist/core/navigation/navigationModel.d.ts +165 -0
- package/dist/core/navigation/navigationModel.js +14 -0
- package/dist/core/navigation/navigationSelectors.d.ts +63 -0
- package/dist/core/navigation/navigationSelectors.js +167 -0
- package/dist/core/navigation/searchCatalogue.d.ts +23 -0
- package/dist/core/navigation/searchCatalogue.js +76 -0
- package/dist/core/net/reconnectSchedule.d.ts +46 -0
- package/dist/core/net/reconnectSchedule.js +38 -0
- package/dist/core/palette/ShortcutRegistry.d.ts +33 -0
- package/dist/core/palette/ShortcutRegistry.js +52 -0
- package/dist/core/palette/fuzzyRank.d.ts +18 -0
- package/dist/core/palette/fuzzyRank.js +73 -0
- package/dist/core/palette/hotkeys.d.ts +28 -0
- package/dist/core/palette/hotkeys.js +118 -0
- package/dist/core/palette/index.d.ts +3 -0
- package/dist/core/palette/index.js +8 -0
- package/dist/core/schema/projection.d.ts +84 -0
- package/dist/core/schema/projection.js +184 -0
- package/dist/core/session/claims.d.ts +145 -0
- package/dist/core/session/claims.js +157 -0
- package/dist/core/sharing/index.d.ts +5 -0
- package/dist/core/sharing/index.js +4 -0
- package/dist/core/sharing/model.d.ts +195 -0
- package/dist/core/sharing/model.js +150 -0
- package/dist/core/site/siteCopy.d.ts +92 -0
- package/dist/core/site/siteCopy.js +91 -0
- package/dist/core/slots.d.ts +22 -0
- package/dist/core/slots.js +31 -0
- package/dist/core/styles/constructableSheet.d.ts +51 -0
- package/dist/core/styles/constructableSheet.js +80 -0
- package/dist/core/tokens/registry.d.ts +119 -0
- package/dist/core/tokens/registry.js +447 -0
- package/dist/core/uiScale/index.d.ts +8 -0
- package/dist/core/uiScale/index.js +8 -0
- package/dist/core/uiScale/scale.d.ts +36 -0
- package/dist/core/uiScale/scale.js +47 -0
- package/dist/core/uiScale/uiScaleStore.d.ts +47 -0
- package/dist/core/uiScale/uiScaleStore.js +63 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.d.ts +1 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +23 -0
- package/dist/core/wire/index.d.ts +17 -0
- package/dist/core/wire/index.js +17 -0
- package/dist/core/wire/ingress.d.ts +44 -0
- package/dist/core/wire/ingress.js +134 -0
- package/dist/core/wire/marshalWorker.d.ts +70 -0
- package/dist/core/wire/marshalWorker.js +273 -0
- package/dist/core/wire/marshalling.d.ts +122 -0
- package/dist/core/wire/marshalling.js +126 -0
- package/dist/core/wire/wireHub.d.ts +80 -0
- package/dist/core/wire/wireHub.js +116 -0
- package/dist/ctgr/base64url.d.ts +8 -0
- package/dist/ctgr/base64url.js +39 -0
- package/dist/ctgr/chunkAccumulator.d.ts +214 -0
- package/dist/ctgr/chunkAccumulator.js +307 -0
- package/dist/ctgr/chunkStore.d.ts +24 -0
- package/dist/ctgr/chunkStore.js +125 -0
- package/dist/ctgr/codec.d.ts +44 -0
- package/dist/ctgr/codec.js +153 -0
- package/dist/ctgr/compress.d.ts +2 -0
- package/dist/ctgr/compress.js +20 -0
- package/dist/ctgr/crc32.d.ts +2 -0
- package/dist/ctgr/crc32.js +23 -0
- package/dist/ctgr/digest.d.ts +1 -0
- package/dist/ctgr/digest.js +15 -0
- package/dist/ctgr/index.d.ts +9 -0
- package/dist/ctgr/index.js +17 -0
- package/dist/ctgr/types.d.ts +64 -0
- package/dist/ctgr/types.js +21 -0
- package/dist/ctgr/v0compat.d.ts +27 -0
- package/dist/ctgr/v0compat.js +128 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +17 -0
- package/dist/shared/errors.d.ts +101 -0
- package/dist/shared/errors.js +99 -0
- package/dist/testing/bytes.d.ts +32 -0
- package/dist/testing/bytes.js +33 -0
- package/dist/testing/domInspect.d.ts +59 -0
- package/dist/testing/domInspect.js +115 -0
- package/dist/testing/fakeFetch.d.ts +23 -0
- package/dist/testing/fakeFetch.js +21 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.js +11 -0
- package/package.json +373 -0
- package/src/client/wsWire.ts +83 -0
- package/src/config/appConfig.spec.ts +158 -0
- package/src/config/appConfig.ts +372 -0
- package/src/core/activity/index.ts +21 -0
- package/src/core/activity/model.ts +160 -0
- package/src/core/analytics/index.ts +18 -0
- package/src/core/analytics/range.spec.ts +280 -0
- package/src/core/analytics/range.ts +383 -0
- package/src/core/analytics/schedule.spec.ts +311 -0
- package/src/core/analytics/schedule.ts +624 -0
- package/src/core/category/category.spec.ts +144 -0
- package/src/core/category/index.ts +19 -0
- package/src/core/category/memoryStore.ts +104 -0
- package/src/core/category/tree.ts +118 -0
- package/src/core/category/types.ts +59 -0
- package/src/core/color.spec.ts +35 -0
- package/src/core/color.ts +35 -0
- package/src/core/colorScheme/colorScheme.spec.ts +127 -0
- package/src/core/colorScheme/colorSchemeStore.ts +184 -0
- package/src/core/colorScheme/index.ts +23 -0
- package/src/core/domainOwners.ts +32 -0
- package/src/core/download.spec.ts +38 -0
- package/src/core/download.ts +35 -0
- package/src/core/events/ccEventBus.spec.ts +90 -0
- package/src/core/events/ccEventBus.ts +120 -0
- package/src/core/file-tree/fileTreeModel.spec.ts +462 -0
- package/src/core/file-tree/fileTreeModel.ts +511 -0
- package/src/core/folder-tree/actions.spec.ts +103 -0
- package/src/core/folder-tree/actions.ts +153 -0
- package/src/core/folder-tree/index.ts +46 -0
- package/src/core/folder-tree/path.spec.ts +113 -0
- package/src/core/folder-tree/path.ts +148 -0
- package/src/core/folder-tree/tree.spec.ts +157 -0
- package/src/core/folder-tree/tree.ts +244 -0
- package/src/core/layout/index.ts +7 -0
- package/src/core/master-lock/index.ts +50 -0
- package/src/core/master-lock/kdf.spec.ts +95 -0
- package/src/core/master-lock/kdf.ts +145 -0
- package/src/core/master-lock/policy.ts +55 -0
- package/src/core/master-lock/presence.spec.ts +73 -0
- package/src/core/master-lock/presence.ts +71 -0
- package/src/core/master-lock/wire.ts +155 -0
- package/src/core/media/bgRemoval.spec.ts +120 -0
- package/src/core/media/bgRemoval.ts +123 -0
- package/src/core/media/ffmpeg.spec.ts +135 -0
- package/src/core/media/ffmpeg.ts +162 -0
- package/src/core/media/format.spec.ts +41 -0
- package/src/core/media/format.ts +58 -0
- package/src/core/media/index.ts +105 -0
- package/src/core/media/mediaItem.spec.ts +46 -0
- package/src/core/media/mediaItem.ts +40 -0
- package/src/core/media/renderPlan.spec.ts +140 -0
- package/src/core/media/renderPlan.ts +318 -0
- package/src/core/media/segment.spec.ts +159 -0
- package/src/core/media/segment.ts +352 -0
- package/src/core/media/size.spec.ts +19 -0
- package/src/core/media/size.ts +3 -0
- package/src/core/media/trim.spec.ts +75 -0
- package/src/core/media/trim.ts +60 -0
- package/src/core/media/types.ts +147 -0
- package/src/core/media/uploadLimits.ts +18 -0
- package/src/core/media/validate.spec.ts +106 -0
- package/src/core/media/validate.ts +131 -0
- package/src/core/media/videoCuts.spec.ts +327 -0
- package/src/core/media/videoCuts.ts +355 -0
- package/src/core/navigation/breadcrumbCompat.ts +44 -0
- package/src/core/navigation/coverageTest.ts +54 -0
- package/src/core/navigation/index.ts +50 -0
- package/src/core/navigation/migrateNavPreferences.ts +119 -0
- package/src/core/navigation/navigation.spec.ts +388 -0
- package/src/core/navigation/navigationModel.ts +185 -0
- package/src/core/navigation/navigationSelectors.ts +206 -0
- package/src/core/navigation/searchCatalogue.ts +89 -0
- package/src/core/net/reconnectSchedule.spec.ts +49 -0
- package/src/core/net/reconnectSchedule.ts +78 -0
- package/src/core/palette/ShortcutRegistry.ts +69 -0
- package/src/core/palette/fuzzyRank.ts +78 -0
- package/src/core/palette/hotkeys.ts +132 -0
- package/src/core/palette/index.ts +22 -0
- package/src/core/palette/palette.spec.ts +160 -0
- package/src/core/schema/projection.spec.ts +122 -0
- package/src/core/schema/projection.ts +225 -0
- package/src/core/session/claims.ts +181 -0
- package/src/core/sharing/index.ts +33 -0
- package/src/core/sharing/model.spec.ts +104 -0
- package/src/core/sharing/model.ts +296 -0
- package/src/core/site/siteCopy.ts +115 -0
- package/src/core/slots.ts +38 -0
- package/src/core/styles/constructableSheet.ts +86 -0
- package/src/core/tokens/registry.spec.ts +160 -0
- package/src/core/tokens/registry.ts +536 -0
- package/src/core/uiScale/index.ts +32 -0
- package/src/core/uiScale/scale.ts +63 -0
- package/src/core/uiScale/uiScale.spec.ts +77 -0
- package/src/core/uiScale/uiScaleStore.ts +100 -0
- package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +24 -0
- package/src/core/wire/index.ts +56 -0
- package/src/core/wire/ingress.spec.ts +276 -0
- package/src/core/wire/ingress.ts +182 -0
- package/src/core/wire/marshalWorker.spec.ts +196 -0
- package/src/core/wire/marshalWorker.ts +361 -0
- package/src/core/wire/marshalling.spec.ts +139 -0
- package/src/core/wire/marshalling.ts +238 -0
- package/src/core/wire/wireHub.spec.ts +137 -0
- package/src/core/wire/wireHub.ts +213 -0
- package/src/ctgr/base64url.ts +44 -0
- package/src/ctgr/chunkAccumulator.ts +403 -0
- package/src/ctgr/chunkStore.spec.ts +101 -0
- package/src/ctgr/chunkStore.ts +168 -0
- package/src/ctgr/codec.spec.ts +208 -0
- package/src/ctgr/codec.ts +202 -0
- package/src/ctgr/compress.ts +21 -0
- package/src/ctgr/crc32.ts +23 -0
- package/src/ctgr/digest.ts +14 -0
- package/src/ctgr/index.ts +39 -0
- package/src/ctgr/types.ts +80 -0
- package/src/ctgr/v0compat.spec.ts +73 -0
- package/src/ctgr/v0compat.ts +156 -0
- package/src/declaredDepsAreImported.spec.ts +87 -0
- package/src/index.ts +25 -0
- package/src/leafSubpathsImportNothing.spec.ts +287 -0
- package/src/lockfileIsTracked.spec.ts +50 -0
- package/src/namedSubpathsResolve.spec.ts +122 -0
- package/src/noPathDeps.spec.ts +24 -0
- package/src/publishShape.spec.ts +89 -0
- package/src/shared/errors.ts +153 -0
- package/src/skippedTestsAreAnswered.spec.ts +221 -0
- package/src/testTmpRoot.spec.ts +102 -0
- package/src/testing/bytes.ts +38 -0
- package/src/testing/domInspect.spec.ts +79 -0
- package/src/testing/domInspect.ts +127 -0
- package/src/testing/fakeFetch.ts +26 -0
- package/src/testing/index.ts +11 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GuardEngine session assertion (02 §4.2) — the one claims vocabulary the
|
|
3
|
+
* server signs, the wire carries, and the client reads.
|
|
4
|
+
*
|
|
5
|
+
* The single most important property here is that **the wire only signals**.
|
|
6
|
+
* `PrivateModePayload.claims` is a decoded convenience copy and is marked
|
|
7
|
+
* advisory in the type's own documentation because panes kept treating it as an
|
|
8
|
+
* authorization result: one pane unlocking would reveal another pane's bytes
|
|
9
|
+
* because both read the same "unlocked" boolean. Every server route that serves
|
|
10
|
+
* private bytes or mutates data re-verifies the opaque `token` per request
|
|
11
|
+
* (03 §2.3), so a forged or stale `claims` object buys nothing.
|
|
12
|
+
*
|
|
13
|
+
* Both schemas are `.strict()`: an unknown claim key is a forged or
|
|
14
|
+
* version-skewed token, and silently ignoring it is how a `permissions` typo
|
|
15
|
+
* becomes an accidental grant.
|
|
16
|
+
*/
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
|
|
19
|
+
/** Roles, ordered least→most privileged. RBAC checks compare permissions, not this. */
|
|
20
|
+
export const CC_ROLES = ['VIEWER', 'MEMBER', 'ADMIN', 'OWNER'] as const;
|
|
21
|
+
export type CCRole = (typeof CC_ROLES)[number];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The permission that gates infrastructure features (02 §4.4). `configEditor`
|
|
25
|
+
* and `dbAdmin` can read plaintext env values and run destructive DB operations,
|
|
26
|
+
* which is not an "every team member" power even when the feature is
|
|
27
|
+
* config-enabled.
|
|
28
|
+
*/
|
|
29
|
+
export const INFRA_WRITE = 'infra:write';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reads the audit trail (`GET /api/guard/audit`) — owner decision D27, Option C.
|
|
33
|
+
*
|
|
34
|
+
* A SEPARATE permission from {@link INFRA_WRITE}, and not granted to ADMIN by
|
|
35
|
+
* default. Three things had to be true at once and no single existing grant made
|
|
36
|
+
* them so:
|
|
37
|
+
*
|
|
38
|
+
* * The trail is the most sensitive read surface the product has — every
|
|
39
|
+
* action every member of a tenant has taken. In a tenant where ADMINs manage
|
|
40
|
+
* each other it is also a record of what the OTHER ADMINs did, and deciding
|
|
41
|
+
* on every customer's behalf that ADMINs watch each other is not a default
|
|
42
|
+
* worth shipping.
|
|
43
|
+
* * The person who actually wants a change log is usually an ADMIN, and
|
|
44
|
+
* OWNER-only means they must ask the account owner — who is frequently the
|
|
45
|
+
* person least interested in the request.
|
|
46
|
+
* * Reusing `infra:write` (what shipped before this) makes reading the trail
|
|
47
|
+
* inseparable from editing config and running destructive DB operations.
|
|
48
|
+
* They are different powers and a customer will eventually want to split
|
|
49
|
+
* them.
|
|
50
|
+
*
|
|
51
|
+
* So: OWNER carries it by role, and anyone else gets it as an explicit
|
|
52
|
+
* per-membership grant through `identity.setMembershipPermissions` — the path
|
|
53
|
+
* that already exists. Nothing is surprising by default, and delegating is one
|
|
54
|
+
* row rather than a redesign.
|
|
55
|
+
*/
|
|
56
|
+
export const AUDIT_READ = 'audit:read';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Role ordering, as a number, for the privilege-escalation checks (02 §3). Only
|
|
60
|
+
* ADMINISTRATION compares roles; a route still compares PERMISSIONS.
|
|
61
|
+
*/
|
|
62
|
+
export const CC_ROLE_RANK: Readonly<Record<CCRole, number>> = Object.freeze({
|
|
63
|
+
VIEWER: 0,
|
|
64
|
+
MEMBER: 1,
|
|
65
|
+
ADMIN: 2,
|
|
66
|
+
OWNER: 3,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/** Is `role` at least as privileged as `floor`? */
|
|
70
|
+
export const roleAtLeast = (role: CCRole, floor: CCRole): boolean => CC_ROLE_RANK[role] >= CC_ROLE_RANK[floor];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The permissions a ROLE carries by itself (02 §3).
|
|
74
|
+
*
|
|
75
|
+
* Without this table `guard.features.rbac` was a switch that locked everyone
|
|
76
|
+
* out: memberships are created with an EMPTY grant array, every permission-gated
|
|
77
|
+
* route compares `claims.permissions`, and nothing anywhere derived one from the
|
|
78
|
+
* other — so with RBAC on, a freshly signed-up OWNER of a brand-new tenant could
|
|
79
|
+
* not write a note, and there was nobody above them to grant it. Roles are the
|
|
80
|
+
* grant surface (`memberships` IS the RBAC surface); explicit per-membership
|
|
81
|
+
* grants stay as the exception, not the only path.
|
|
82
|
+
*
|
|
83
|
+
* The vocabulary is deliberately exactly the permissions the tree actually
|
|
84
|
+
* enforces — `knowledge:write`/`knowledge:delete` (the knowledge mixin's
|
|
85
|
+
* sub-blocks), `infra:write` (guard secrets + the privileged ops) and
|
|
86
|
+
* `audit:read` (the trail). Inventing grants nothing checks would be a table
|
|
87
|
+
* that drifts from the moment it lands.
|
|
88
|
+
*/
|
|
89
|
+
export const CC_ROLE_PERMISSIONS: Readonly<Record<CCRole, readonly string[]>> = Object.freeze({
|
|
90
|
+
// Read-only. Every listing route is unguarded-by-permission, so a VIEWER sees
|
|
91
|
+
// the app and mutates nothing.
|
|
92
|
+
VIEWER: Object.freeze([]),
|
|
93
|
+
// The everyday contributor: writes content, deletes nothing.
|
|
94
|
+
MEMBER: Object.freeze(['knowledge:write']),
|
|
95
|
+
// Runs the tenant's content and its people; still not its infrastructure, and
|
|
96
|
+
// deliberately NOT the audit trail (D27 Option C): in a tenant where ADMINs
|
|
97
|
+
// manage each other, the trail is a record of what the other ADMINs did, and
|
|
98
|
+
// that is a decision each customer makes with a per-membership grant rather
|
|
99
|
+
// than one this table makes for all of them.
|
|
100
|
+
ADMIN: Object.freeze(['knowledge:write', 'knowledge:delete']),
|
|
101
|
+
// Infrastructure too: secrets, config editor, db admin — and the trail.
|
|
102
|
+
OWNER: Object.freeze(['knowledge:write', 'knowledge:delete', AUDIT_READ, INFRA_WRITE]),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* What a membership actually grants: the role's permissions UNION any explicit
|
|
107
|
+
* per-membership grants (`identity.setMembershipPermissions`). Deduplicated and
|
|
108
|
+
* sorted so a claims set is stable for a given grant — an unstable permission
|
|
109
|
+
* order makes two equivalent tokens compare unequal in a test and in a cache key.
|
|
110
|
+
*/
|
|
111
|
+
export const effectivePermissions = (role: CCRole, grants: readonly string[] = []): string[] =>
|
|
112
|
+
[...new Set([...CC_ROLE_PERMISSIONS[role], ...grants])].sort();
|
|
113
|
+
|
|
114
|
+
export const CCUserClaimsSchema = z
|
|
115
|
+
.object({
|
|
116
|
+
tenantId: z.string().min(1),
|
|
117
|
+
userId: z.string().min(1),
|
|
118
|
+
role: z.enum(CC_ROLES),
|
|
119
|
+
/** 'media:write', 'knowledge:delete', 'infra:write', … */
|
|
120
|
+
permissions: z.array(z.string()).default([]),
|
|
121
|
+
/** Private-mode reveal granted for this session. */
|
|
122
|
+
privateScope: z.boolean().default(false),
|
|
123
|
+
/** Epoch SECONDS (not ms) — the signing and verification paths must agree. */
|
|
124
|
+
exp: z.number().int().positive(),
|
|
125
|
+
/** Revocation handle: lock adds it to the denylist until `exp` (02 §4.2). */
|
|
126
|
+
jti: z.string().min(1),
|
|
127
|
+
})
|
|
128
|
+
.strict();
|
|
129
|
+
|
|
130
|
+
export type CCUserClaims = z.infer<typeof CCUserClaimsSchema>;
|
|
131
|
+
|
|
132
|
+
export const PrivateModePayloadSchema = z
|
|
133
|
+
.object({
|
|
134
|
+
unlocked: z.boolean().default(false),
|
|
135
|
+
/** Signed claims token; opaque to consumers — never parsed on the client for access. */
|
|
136
|
+
token: z.string().nullable().default(null),
|
|
137
|
+
/** Decoded convenience copy — ADVISORY ONLY. Never an authorization result. */
|
|
138
|
+
claims: CCUserClaimsSchema.nullable().default(null),
|
|
139
|
+
})
|
|
140
|
+
.strict();
|
|
141
|
+
|
|
142
|
+
export type PrivateModePayload = z.infer<typeof PrivateModePayloadSchema>;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The canonical locked assertion. Lock re-asserts exactly this on the
|
|
146
|
+
* `session:private-mode` wire, in every tab (03 §6.5) — a partially-cleared
|
|
147
|
+
* payload (token nulled but `unlocked` left true) is the shape that kept
|
|
148
|
+
* revealing stale panes after a lock.
|
|
149
|
+
*/
|
|
150
|
+
export const LOCKED_PRIVATE_MODE: PrivateModePayload = Object.freeze({
|
|
151
|
+
unlocked: false,
|
|
152
|
+
token: null,
|
|
153
|
+
claims: null,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/** Does this claims set carry `permission`? The one lookup RBAC and §4.4 share. */
|
|
157
|
+
export const hasPermission = (claims: CCUserClaims | null | undefined, permission: string): boolean =>
|
|
158
|
+
claims?.permissions.includes(permission) === true;
|
|
159
|
+
|
|
160
|
+
/** Convenience for the privileged-feature gate (02 §4.4). */
|
|
161
|
+
export const hasInfraWrite = (claims: CCUserClaims | null | undefined): boolean =>
|
|
162
|
+
hasPermission(claims, INFRA_WRITE);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* May this session read the audit trail (D27)?
|
|
166
|
+
*
|
|
167
|
+
* The CLIENT's copy of the route's own check, and it is the permission rather
|
|
168
|
+
* than the role for a reason the previous shape got wrong: a role test here
|
|
169
|
+
* drifts from the server's answer the moment a grant is handed out explicitly,
|
|
170
|
+
* which under Option C is the ONLY way anyone but an OWNER gets it.
|
|
171
|
+
*/
|
|
172
|
+
export const canReadAuditTrail = (claims: CCUserClaims | null | undefined): boolean =>
|
|
173
|
+
hasPermission(claims, AUDIT_READ);
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Has this token expired, given a clock in ms? Centralized so the server's
|
|
177
|
+
* verification and the client's proactive-refresh path can never disagree about
|
|
178
|
+
* whether `exp` is seconds or milliseconds.
|
|
179
|
+
*/
|
|
180
|
+
export const claimsExpired = (claims: CCUserClaims, nowMs: number = Date.now()): boolean =>
|
|
181
|
+
claims.exp * 1000 <= nowMs;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The fleet's sharing/access vocabulary — see `./model.ts` for the whole
|
|
2
|
+
* contract. Storage lives in `cursedbelt-server/sharing`, the UI in
|
|
3
|
+
* `cursedbelt/react/sharing`; both speak these words. */
|
|
4
|
+
export {
|
|
5
|
+
accessMeets,
|
|
6
|
+
describeAccess,
|
|
7
|
+
describeShareEvent,
|
|
8
|
+
isShareAccess,
|
|
9
|
+
isShareLevel,
|
|
10
|
+
isShareSubjectType,
|
|
11
|
+
levelsMeeting,
|
|
12
|
+
normalizeSubject,
|
|
13
|
+
normalizeSubjectId,
|
|
14
|
+
resolveAccess,
|
|
15
|
+
resourceKey,
|
|
16
|
+
sameResource,
|
|
17
|
+
sameSubject,
|
|
18
|
+
SHARE_ACTIONS,
|
|
19
|
+
strongerAccess,
|
|
20
|
+
} from "./model";
|
|
21
|
+
export type {
|
|
22
|
+
ShareAccess,
|
|
23
|
+
ShareAction,
|
|
24
|
+
ShareEvent,
|
|
25
|
+
ShareGrant,
|
|
26
|
+
ShareGroup,
|
|
27
|
+
ShareGroupMember,
|
|
28
|
+
ShareLevel,
|
|
29
|
+
ShareResource,
|
|
30
|
+
ShareSubject,
|
|
31
|
+
ShareSubjectType,
|
|
32
|
+
ShareViewer,
|
|
33
|
+
} from "./model";
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The precedence rules, as pure functions — the ONE written definition the SQLite
|
|
3
|
+
* store is checked against (`server/sharing/store.spec.ts` asserts they agree).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
accessMeets,
|
|
8
|
+
describeAccess,
|
|
9
|
+
levelsMeeting,
|
|
10
|
+
normalizeSubjectId,
|
|
11
|
+
resolveAccess,
|
|
12
|
+
resourceKey,
|
|
13
|
+
type ShareGrant,
|
|
14
|
+
strongerAccess,
|
|
15
|
+
} from "./model";
|
|
16
|
+
|
|
17
|
+
const resource = { app: "family", kind: "person", id: "p1" };
|
|
18
|
+
|
|
19
|
+
const grant = (
|
|
20
|
+
subject: ShareGrant["subject"],
|
|
21
|
+
level: ShareGrant["level"],
|
|
22
|
+
id = "p1",
|
|
23
|
+
): ShareGrant => ({
|
|
24
|
+
resource: { ...resource, id },
|
|
25
|
+
subject,
|
|
26
|
+
level,
|
|
27
|
+
grantedBy: "owner@example.com",
|
|
28
|
+
grantedAt: "2026-08-11T00:00:00.000Z",
|
|
29
|
+
note: "",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("the level algebra", () => {
|
|
33
|
+
test("write covers read; read does not cover write", () => {
|
|
34
|
+
expect(accessMeets("write", "read")).toBe(true);
|
|
35
|
+
expect(accessMeets("write", "write")).toBe(true);
|
|
36
|
+
expect(accessMeets("read", "read")).toBe(true);
|
|
37
|
+
expect(accessMeets("read", "write")).toBe(false);
|
|
38
|
+
expect(accessMeets("none", "read")).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("grants only ever widen", () => {
|
|
42
|
+
expect(strongerAccess("read", "write")).toBe("write");
|
|
43
|
+
expect(strongerAccess("write", "read")).toBe("write");
|
|
44
|
+
expect(strongerAccess("none", "read")).toBe("read");
|
|
45
|
+
expect(strongerAccess("none", "none")).toBe("none");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("`levelsMeeting` is what a store's IN (…) list must hold", () => {
|
|
49
|
+
expect(levelsMeeting("read")).toEqual(["read", "write"]);
|
|
50
|
+
expect(levelsMeeting("write")).toEqual(["write"]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("resolveAccess", () => {
|
|
55
|
+
test("takes the strongest grant that reaches the viewer", () => {
|
|
56
|
+
const grants = [
|
|
57
|
+
grant({ type: "user", id: "aunt@example.com" }, "read"),
|
|
58
|
+
grant({ type: "group", id: "g-family" }, "write"),
|
|
59
|
+
];
|
|
60
|
+
expect(
|
|
61
|
+
resolveAccess(grants, resource, {
|
|
62
|
+
subjectId: "aunt@example.com",
|
|
63
|
+
groupIds: ["g-family"],
|
|
64
|
+
}),
|
|
65
|
+
).toBe("write");
|
|
66
|
+
// Without the group membership, only their own grant reaches them.
|
|
67
|
+
expect(resolveAccess(grants, resource, { subjectId: "aunt@example.com" })).toBe("read");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("ignores grants on other resources", () => {
|
|
71
|
+
const grants = [grant({ type: "user", id: "aunt@example.com" }, "write", "p2")];
|
|
72
|
+
expect(resolveAccess(grants, resource, { subjectId: "aunt@example.com" })).toBe("none");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("matches an address case- and whitespace-insensitively", () => {
|
|
76
|
+
const grants = [grant({ type: "user", id: "aunt@example.com" }, "read")];
|
|
77
|
+
expect(resolveAccess(grants, resource, { subjectId: " Aunt@Example.com " })).toBe("read");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("normalizeSubjectId", () => {
|
|
82
|
+
test("lowercases a user address, and only trims a group id", () => {
|
|
83
|
+
expect(normalizeSubjectId("user", " Aunt@Example.COM ")).toBe("aunt@example.com");
|
|
84
|
+
// A group id is minted by an app and may be case-sensitive; lowercasing it
|
|
85
|
+
// would silently collide two distinct ids.
|
|
86
|
+
expect(normalizeSubjectId("group", " G-Family ")).toBe("G-Family");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("resourceKey", () => {
|
|
91
|
+
test("is stable and never parsed back — an id may contain a slash", () => {
|
|
92
|
+
expect(resourceKey({ app: "family", kind: "place", id: "Kingston/Hull" })).toBe(
|
|
93
|
+
"family/place/Kingston/Hull",
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("describeAccess", () => {
|
|
99
|
+
test("gives a UI one wording per level", () => {
|
|
100
|
+
expect(describeAccess("write")).toBe("Can view and change it");
|
|
101
|
+
expect(describeAccess("read")).toBe("Can view it");
|
|
102
|
+
expect(describeAccess("none")).toBe("No access");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fleet's ONE sharing/access vocabulary — pure, framework-free, storage-free.
|
|
3
|
+
*
|
|
4
|
+
* ── What this replaces ──────────────────────────────────────────────────────
|
|
5
|
+
* `cursedalchemy` carried a unified `shares` + `group_shares` pair across ~30
|
|
6
|
+
* resource types: per-record read/write, an audit trail and notification hooks,
|
|
7
|
+
* all in one place. That app is superseded, and the fleet has been re-deriving
|
|
8
|
+
* the same idea one app at a time — `apps/family` grew a whole roles model plus
|
|
9
|
+
* a `guards.ts` because it needed "who may change THIS record", and its own
|
|
10
|
+
* `CLAUDE.md` recorded the hole it could not close: *"No per-item ACL.
|
|
11
|
+
* Visibility is still two levels rather than a per-person grant list."*
|
|
12
|
+
*
|
|
13
|
+
* `life`, `money` and `arcade` are queued to need exactly that. So the PATTERN
|
|
14
|
+
* (not ca's code) lands here once:
|
|
15
|
+
*
|
|
16
|
+
* a **resource** is `(app, kind, id)` — an opaque triple, so nothing in this
|
|
17
|
+
* module has to know what a person, a budget or a saved game is;
|
|
18
|
+
* a **grant** is `(subject, resource, level, grantedBy, grantedAt)`;
|
|
19
|
+
* a **subject** is a user or a GROUP, because "share it with the family" is one
|
|
20
|
+
* act the owner performs once, not one act per relative;
|
|
21
|
+
* **revocation and the audit trail are part of the primitive**, never a caller's
|
|
22
|
+
* concern — an app that has to remember to write the audit row is an app whose
|
|
23
|
+
* audit trail has holes exactly where somebody was in a hurry.
|
|
24
|
+
*
|
|
25
|
+
* ── Why the vocabulary is separate from the store ────────────────────────────
|
|
26
|
+
* `cursedbelt-server/sharing` owns the SQLite half and `cursedbelt/react/sharing`
|
|
27
|
+
* draws the panel. Both need the same words, and the react half must not pull
|
|
28
|
+
* `bun:sqlite` into a browser bundle. So the types and the precedence rules live
|
|
29
|
+
* here (`cursedbelt-core/sharing`), which is typechecked with `types: []` — it cannot
|
|
30
|
+
* accidentally depend on a runtime.
|
|
31
|
+
*
|
|
32
|
+
* ── The precedence rule, in one sentence ────────────────────────────────────
|
|
33
|
+
* A subject's access to a resource is the STRONGEST of every grant that reaches
|
|
34
|
+
* them — their own and every group they are in. Grants only ever widen; there is
|
|
35
|
+
* deliberately no "deny" grant. A deny row in a table whose whole job is "who may
|
|
36
|
+
* see this" produces the state nobody can reason about (a user in two groups, one
|
|
37
|
+
* allowed and one denied) and the answer would then depend on row order. An app
|
|
38
|
+
* that needs an exception denies it in its OWN model — `apps/family`'s tri-state
|
|
39
|
+
* `members` columns are exactly that, and they sit ABOVE this layer rather than
|
|
40
|
+
* inside it.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** What a grant lets a subject do. Two levels, on purpose: ca had two and used
|
|
44
|
+
* both; a third ("comment", "admin") is a per-app concept every time. */
|
|
45
|
+
export type ShareLevel = "read" | "write";
|
|
46
|
+
|
|
47
|
+
/** The answer to "what may this subject do with this resource" — a level, or
|
|
48
|
+
* nothing at all. Separate from {@link ShareLevel} so a function returning "no
|
|
49
|
+
* access" cannot be mistaken for one returning a grant. */
|
|
50
|
+
export type ShareAccess = "none" | ShareLevel;
|
|
51
|
+
|
|
52
|
+
/** A grant is made to a person or to a named group of people. */
|
|
53
|
+
export type ShareSubjectType = "user" | "group";
|
|
54
|
+
|
|
55
|
+
export interface ShareSubject {
|
|
56
|
+
type: ShareSubjectType;
|
|
57
|
+
/** A fleet account address for `user`, a group id for `group`. Normalized —
|
|
58
|
+
* see {@link normalizeSubjectId}. */
|
|
59
|
+
id: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What is being shared: `(app, kind, id)`.
|
|
64
|
+
*
|
|
65
|
+
* `app` is the satellite ("family", "life"), `kind` is that app's own word for the
|
|
66
|
+
* sort of record ("person", "budget", "list"), `id` is the row. Nothing here
|
|
67
|
+
* validates the pair against a schema, and that is the point — a new kind is a
|
|
68
|
+
* string an app already has, not a migration in `cursedbelt`.
|
|
69
|
+
*/
|
|
70
|
+
export interface ShareResource {
|
|
71
|
+
app: string;
|
|
72
|
+
kind: string;
|
|
73
|
+
id: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One grant. `grantedAt` is an ISO-8601 string, matching the fleet's audit
|
|
77
|
+
* vocabulary (the audit module behind `cursedbelt-server`) rather than the epoch-millis apps use
|
|
78
|
+
* for their own rows — an audit trail is read by humans far more often than it is
|
|
79
|
+
* sorted by a machine. */
|
|
80
|
+
export interface ShareGrant {
|
|
81
|
+
resource: ShareResource;
|
|
82
|
+
subject: ShareSubject;
|
|
83
|
+
level: ShareLevel;
|
|
84
|
+
/** The account that made the grant. Never inferred from the session inside the
|
|
85
|
+
* store — the caller passes it, because the store has no session. */
|
|
86
|
+
grantedBy: string;
|
|
87
|
+
grantedAt: string;
|
|
88
|
+
/** Why, for whoever reads the list six months later. */
|
|
89
|
+
note: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Who is asking. `groupIds` may be omitted, in which case a store resolves the
|
|
93
|
+
* subject's groups itself; passing them is an optimization for a caller that
|
|
94
|
+
* already knows. */
|
|
95
|
+
export interface ShareViewer {
|
|
96
|
+
subjectId: string;
|
|
97
|
+
groupIds?: readonly string[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A named set of people — "the family", "the trustees". Groups are what make
|
|
101
|
+
* "share this with everybody who matters" one act instead of eleven. */
|
|
102
|
+
export interface ShareGroup {
|
|
103
|
+
id: string;
|
|
104
|
+
name: string;
|
|
105
|
+
createdBy: string;
|
|
106
|
+
createdAt: string;
|
|
107
|
+
note: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ShareGroupMember {
|
|
111
|
+
groupId: string;
|
|
112
|
+
subjectId: string;
|
|
113
|
+
addedBy: string;
|
|
114
|
+
addedAt: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Everything that can happen to a grant or a group.
|
|
119
|
+
*
|
|
120
|
+
* `regrade` is its own action rather than a second `grant`: raising somebody from
|
|
121
|
+
* read to write is the change a reader of the trail is looking for, and a trail
|
|
122
|
+
* that records it as another grant makes that invisible.
|
|
123
|
+
*/
|
|
124
|
+
export type ShareAction =
|
|
125
|
+
| "grant"
|
|
126
|
+
| "regrade"
|
|
127
|
+
| "revoke"
|
|
128
|
+
| "group.create"
|
|
129
|
+
| "group.rename"
|
|
130
|
+
| "group.delete"
|
|
131
|
+
| "group.join"
|
|
132
|
+
| "group.leave";
|
|
133
|
+
|
|
134
|
+
export const SHARE_ACTIONS: readonly ShareAction[] = [
|
|
135
|
+
"grant",
|
|
136
|
+
"regrade",
|
|
137
|
+
"revoke",
|
|
138
|
+
"group.create",
|
|
139
|
+
"group.rename",
|
|
140
|
+
"group.delete",
|
|
141
|
+
"group.join",
|
|
142
|
+
"group.leave",
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One line of the audit trail.
|
|
147
|
+
*
|
|
148
|
+
* 🔴 **An event outlives the grant it describes.** Revoking deletes the grant row
|
|
149
|
+
* and appends an event; nothing in this primitive ever deletes an event. An audit
|
|
150
|
+
* trail that is a JOIN onto live grants can only tell you about access that still
|
|
151
|
+
* exists, which is the opposite of the question anybody asks it ("who COULD see
|
|
152
|
+
* this in March?"). That is why `resource`, `subject` and `level` are copied onto
|
|
153
|
+
* the event rather than referenced.
|
|
154
|
+
*/
|
|
155
|
+
export interface ShareEvent {
|
|
156
|
+
id: number;
|
|
157
|
+
at: string;
|
|
158
|
+
action: ShareAction;
|
|
159
|
+
/** Null for a group lifecycle event, which is about no single resource. */
|
|
160
|
+
resource: ShareResource | null;
|
|
161
|
+
subject: ShareSubject;
|
|
162
|
+
/** The level AFTER the action; for a revoke, the level that was taken away. */
|
|
163
|
+
level: ShareLevel | null;
|
|
164
|
+
/** Only set by `regrade` — what it was before. */
|
|
165
|
+
previousLevel: ShareLevel | null;
|
|
166
|
+
actor: string;
|
|
167
|
+
note: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const ACCESS_RANK: Record<ShareAccess, number> = { none: 0, read: 1, write: 2 };
|
|
171
|
+
|
|
172
|
+
export const isShareLevel = (value: unknown): value is ShareLevel =>
|
|
173
|
+
value === "read" || value === "write";
|
|
174
|
+
|
|
175
|
+
export const isShareAccess = (value: unknown): value is ShareAccess =>
|
|
176
|
+
value === "none" || isShareLevel(value);
|
|
177
|
+
|
|
178
|
+
export const isShareSubjectType = (value: unknown): value is ShareSubjectType =>
|
|
179
|
+
value === "user" || value === "group";
|
|
180
|
+
|
|
181
|
+
/** The stronger of two answers. Grants only widen, so this is the whole
|
|
182
|
+
* resolution rule. */
|
|
183
|
+
export const strongerAccess = (a: ShareAccess, b: ShareAccess): ShareAccess =>
|
|
184
|
+
ACCESS_RANK[a] >= ACCESS_RANK[b] ? a : b;
|
|
185
|
+
|
|
186
|
+
/** Does this access cover what is being attempted? `write` covers `read`; nothing
|
|
187
|
+
* covers a level above itself. */
|
|
188
|
+
export const accessMeets = (access: ShareAccess, atLeast: ShareLevel): boolean =>
|
|
189
|
+
ACCESS_RANK[access] >= ACCESS_RANK[atLeast];
|
|
190
|
+
|
|
191
|
+
/** The levels a grant must carry to satisfy `atLeast`, for a store building an
|
|
192
|
+
* `IN (…)` list. `read` is satisfied by either level; `write` only by itself. */
|
|
193
|
+
export const levelsMeeting = (atLeast: ShareLevel): readonly ShareLevel[] =>
|
|
194
|
+
atLeast === "read" ? ["read", "write"] : ["write"];
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 🔴 The one normalization, and it is a security property rather than tidiness.
|
|
198
|
+
*
|
|
199
|
+
* A user subject is a fleet address, and addresses arrive from a form: `" Ameena@
|
|
200
|
+
* Example.com "`. Storing that verbatim means the grant the owner just made does
|
|
201
|
+
* not match the session that signs in, so the app silently answers "no access"
|
|
202
|
+
* and the owner's only recourse is to guess at whitespace. Lowercasing is also
|
|
203
|
+
* what makes a grant idempotent — one row per (subject, resource), which is what
|
|
204
|
+
* `regrade` depends on.
|
|
205
|
+
*
|
|
206
|
+
* A GROUP id is minted by the app, so it is trimmed but NOT lowercased: doing so
|
|
207
|
+
* would silently collide two ids that differ only in case, and an app is entitled
|
|
208
|
+
* to mint case-sensitive ids.
|
|
209
|
+
*/
|
|
210
|
+
export const normalizeSubjectId = (type: ShareSubjectType, id: string): string =>
|
|
211
|
+
type === "user" ? id.trim().toLowerCase() : id.trim();
|
|
212
|
+
|
|
213
|
+
export const normalizeSubject = (subject: ShareSubject): ShareSubject => ({
|
|
214
|
+
type: subject.type,
|
|
215
|
+
id: normalizeSubjectId(subject.type, subject.id),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
/** A stable string for a resource — for a map key, a log line or a test name.
|
|
219
|
+
* Never parsed back: `id` may contain anything, including a slash. */
|
|
220
|
+
export const resourceKey = (resource: ShareResource): string =>
|
|
221
|
+
`${resource.app}/${resource.kind}/${resource.id}`;
|
|
222
|
+
|
|
223
|
+
export const sameResource = (a: ShareResource, b: ShareResource): boolean =>
|
|
224
|
+
a.app === b.app && a.kind === b.kind && a.id === b.id;
|
|
225
|
+
|
|
226
|
+
export const sameSubject = (a: ShareSubject, b: ShareSubject): boolean =>
|
|
227
|
+
a.type === b.type && a.id === b.id;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The precedence rule as a pure function: the strongest grant that reaches this
|
|
231
|
+
* viewer, across their own grants and their groups'.
|
|
232
|
+
*
|
|
233
|
+
* The SQLite store answers the same question in SQL (it must — a read that
|
|
234
|
+
* filters in JavaScript cannot keep a COUNT honest), so this exists as the ONE
|
|
235
|
+
* written definition both halves are checked against, and as the answer for a
|
|
236
|
+
* caller holding grants in memory. If the two ever disagree, this one is right.
|
|
237
|
+
*/
|
|
238
|
+
export function resolveAccess(
|
|
239
|
+
grants: readonly ShareGrant[],
|
|
240
|
+
resource: ShareResource,
|
|
241
|
+
viewer: ShareViewer,
|
|
242
|
+
): ShareAccess {
|
|
243
|
+
const subjectId = normalizeSubjectId("user", viewer.subjectId);
|
|
244
|
+
const groups = new Set((viewer.groupIds ?? []).map((id) => normalizeSubjectId("group", id)));
|
|
245
|
+
let access: ShareAccess = "none";
|
|
246
|
+
for (const grant of grants) {
|
|
247
|
+
if (!sameResource(grant.resource, resource)) continue;
|
|
248
|
+
const reaches =
|
|
249
|
+
grant.subject.type === "user"
|
|
250
|
+
? grant.subject.id === subjectId
|
|
251
|
+
: groups.has(grant.subject.id);
|
|
252
|
+
if (reaches) access = strongerAccess(access, grant.level);
|
|
253
|
+
}
|
|
254
|
+
return access;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* One line of the audit trail, as a sentence.
|
|
259
|
+
*
|
|
260
|
+
* Pure and here rather than in the panel, because the trail is read in two places
|
|
261
|
+
* that must say the same thing: the sharing panel, and whatever notification the
|
|
262
|
+
* `onEvent` hook eventually sends. A subject label may be supplied (a group's name,
|
|
263
|
+
* a person's display name) — without one it falls back to the id, which is an
|
|
264
|
+
* address or a group id and is always at least true.
|
|
265
|
+
*/
|
|
266
|
+
export function describeShareEvent(event: ShareEvent, subjectLabel?: string): string {
|
|
267
|
+
const who = subjectLabel?.trim() || event.subject.id;
|
|
268
|
+
const target = event.subject.type === "group" ? `the group ${who}` : who;
|
|
269
|
+
switch (event.action) {
|
|
270
|
+
case "grant":
|
|
271
|
+
return `${event.actor} shared this with ${target} (${event.level === "write" ? "can change it" : "view only"})`;
|
|
272
|
+
case "regrade":
|
|
273
|
+
return `${event.actor} changed ${target} from ${event.previousLevel} to ${event.level}`;
|
|
274
|
+
case "revoke":
|
|
275
|
+
return `${event.actor} stopped sharing this with ${target}${event.note ? ` — ${event.note}` : ""}`;
|
|
276
|
+
case "group.create":
|
|
277
|
+
return `${event.actor} created the group ${event.note || who}`;
|
|
278
|
+
case "group.rename":
|
|
279
|
+
return `${event.actor} renamed a group to ${event.note}`;
|
|
280
|
+
case "group.delete":
|
|
281
|
+
return `${event.actor} deleted the group ${who}`;
|
|
282
|
+
case "group.join":
|
|
283
|
+
return `${event.actor} added ${event.note} to the group ${who}`;
|
|
284
|
+
case "group.leave":
|
|
285
|
+
return `${event.actor} removed ${event.note} from the group ${who}`;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** A sentence for a UI: what this access lets somebody do. One wording, so eleven
|
|
290
|
+
* apps do not invent eleven. */
|
|
291
|
+
export const describeAccess = (access: ShareAccess): string =>
|
|
292
|
+
access === "write"
|
|
293
|
+
? "Can view and change it"
|
|
294
|
+
: access === "read"
|
|
295
|
+
? "Can view it"
|
|
296
|
+
: "No access";
|