@cosmicdrift/kumiko-bundled-features 0.269.2 → 0.270.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/package.json +9 -9
- package/src/__tests__/account-security-composition.boot.test.ts +25 -7
- package/src/__tests__/account-security-composition.integration.test.ts +23 -1
- package/src/agent-tools/__tests__/agent-doc-lint.test.ts +3 -1
- package/src/agent-tools/__tests__/feature.test.ts +10 -4
- package/src/auth-email-password/handlers/invite-accept.write.ts +8 -1
- package/src/auth-mfa/handlers/disable.write.ts +7 -1
- package/src/auth-mfa/handlers/enable-confirm.write.ts +7 -1
- package/src/auth-mfa/handlers/enable-start.write.ts +7 -1
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +7 -1
- package/src/auth-mfa/handlers/status.query.ts +7 -1
- package/src/auth-mfa/screens.ts +22 -3
- package/src/channel-in-app/handlers/inbox.query.ts +7 -1
- package/src/channel-in-app/handlers/mark-all-read.write.ts +7 -1
- package/src/channel-in-app/handlers/mark-read.write.ts +7 -1
- package/src/channel-in-app/handlers/unread-count.query.ts +7 -1
- package/src/compliance-profiles/handlers/for-tenant.query.ts +7 -1
- package/src/compliance-profiles/handlers/list-profiles.query.ts +7 -1
- package/src/config/__tests__/config.integration.test.ts +1 -1
- package/src/config/__tests__/tz-resolution.integration.test.ts +1 -1
- package/src/config/handlers/cascade.query.ts +7 -1
- package/src/config/handlers/readiness.query.ts +7 -1
- package/src/config/handlers/reset.write.ts +7 -1
- package/src/config/handlers/schema.query.ts +7 -1
- package/src/config/handlers/set.write.ts +7 -1
- package/src/config/handlers/values.query.ts +7 -1
- package/src/data-retention/handlers/policy-for.query.ts +8 -1
- package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +2 -2
- package/src/delivery/__tests__/delivery-screens.boot.test.ts +2 -4
- package/src/delivery/__tests__/delivery.integration.test.ts +5 -5
- package/src/delivery/handlers/preferences.query.ts +7 -1
- package/src/delivery/handlers/set-preference.write.ts +7 -1
- package/src/derivatives-sharp/__tests__/derivatives-sharp.integration.test.ts +1 -1
- package/src/folders/__tests__/feature.test.ts +14 -5
- package/src/folders/__tests__/folders-parent-visibility.integration.test.ts +6 -1
- package/src/folders/__tests__/folders-read-gate.integration.test.ts +6 -1
- package/src/folders/__tests__/folders.integration.test.ts +6 -1
- package/src/folders/feature.ts +2 -2
- package/src/form-draft/constants.ts +7 -1
- package/src/notes-history/__tests__/notes-read-gate.integration.test.ts +10 -2
- package/src/notes-history/feature.ts +2 -2
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +7 -1
- package/src/personal-access-tokens/handlers/create.write.ts +7 -1
- package/src/personal-access-tokens/handlers/list.query.ts +7 -1
- package/src/personal-access-tokens/handlers/revoke.write.ts +7 -1
- package/src/personal-access-tokens/screens.ts +15 -2
- package/src/secrets/__tests__/access-options-precedence.test.ts +11 -4
- package/src/secrets/__tests__/access-options.integration.test.ts +5 -1
- package/src/secrets/feature.ts +2 -2
- package/src/sessions/handlers/mine.query.ts +7 -1
- package/src/sessions/handlers/revoke-all-others.write.ts +7 -1
- package/src/sessions/handlers/revoke.write.ts +7 -1
- package/src/sessions/screens.ts +7 -1
- package/src/tags/__tests__/feature.test.ts +25 -8
- package/src/tags/__tests__/tags-parent-visibility.integration.test.ts +6 -1
- package/src/tags/__tests__/tags-read-gate.integration.test.ts +7 -2
- package/src/tags/__tests__/tags.integration.test.ts +6 -1
- package/src/tags/constants.ts +1 -1
- package/src/tags/feature.ts +2 -2
- package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +2 -2
- package/src/tenant/__tests__/tenant.integration.test.ts +5 -1
- package/src/tenant/handlers/me.query.ts +7 -1
- package/src/user/handlers/me.query.ts +7 -1
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +6 -1
- package/src/user-data-rights/feature.ts +8 -1
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +7 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +7 -1
- package/src/user-data-rights/handlers/export-status.query.ts +7 -1
- package/src/user-data-rights/handlers/my-audit-log.query.ts +8 -1
- package/src/user-data-rights/handlers/request-deletion.write.ts +7 -1
- package/src/user-data-rights/handlers/request-export.write.ts +7 -1
- package/src/user-data-rights/handlers/restrict-account.write.ts +8 -1
- package/src/user-data-rights/lib/is-admin-actor.ts +1 -1
- package/src/user-profile/__tests__/profile-screen.boot.test.ts +6 -1
- package/src/user-profile/feature.ts +8 -1
- package/src/workflow-runner/__tests__/pending-projection.integration.test.ts +1 -1
|
@@ -46,7 +46,7 @@ const variantTestFeature = defineFeature("derivativessharptest", (r) => {
|
|
|
46
46
|
const meta = await imageMetadata(bytes);
|
|
47
47
|
return { isSuccess: true as const, data: { ...result, ...meta } };
|
|
48
48
|
},
|
|
49
|
-
{ access: { openToAll:
|
|
49
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
50
50
|
);
|
|
51
51
|
});
|
|
52
52
|
|
|
@@ -93,7 +93,9 @@ describe("createFoldersFeature access-options", () => {
|
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
test("access:{openToAll} applies to every write- and query-path", () => {
|
|
96
|
-
const feature = createFoldersFeature({
|
|
96
|
+
const feature = createFoldersFeature({
|
|
97
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
98
|
+
});
|
|
97
99
|
for (const path of [
|
|
98
100
|
"folder:create",
|
|
99
101
|
"folder:update",
|
|
@@ -101,13 +103,20 @@ describe("createFoldersFeature access-options", () => {
|
|
|
101
103
|
"set-folder",
|
|
102
104
|
"clear-folder",
|
|
103
105
|
]) {
|
|
104
|
-
expect(rawWriteAccess(feature, path)).toEqual({
|
|
106
|
+
expect(rawWriteAccess(feature, path)).toEqual({
|
|
107
|
+
openToAll: { reason: "test handler callable by any signed-in test user" },
|
|
108
|
+
});
|
|
105
109
|
}
|
|
106
110
|
});
|
|
107
111
|
|
|
108
112
|
test("access takes precedence over the roles shorthand", () => {
|
|
109
|
-
const feature = createFoldersFeature({
|
|
110
|
-
|
|
113
|
+
const feature = createFoldersFeature({
|
|
114
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
115
|
+
roles: ["Admin"],
|
|
116
|
+
});
|
|
117
|
+
expect(rawWriteAccess(feature, "set-folder")).toEqual({
|
|
118
|
+
openToAll: { reason: "test handler callable by any signed-in test user" },
|
|
119
|
+
});
|
|
111
120
|
});
|
|
112
121
|
});
|
|
113
122
|
|
|
@@ -118,7 +127,7 @@ describe("createFoldersFeature toggleable-option (tier-gating)", () => {
|
|
|
118
127
|
|
|
119
128
|
test("toggleable:{default:false} makes the feature tier-gatable, fail-closed", () => {
|
|
120
129
|
const feature = createFoldersFeature({
|
|
121
|
-
access: { openToAll:
|
|
130
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
122
131
|
toggleable: { default: false },
|
|
123
132
|
});
|
|
124
133
|
expect(feature.toggleableDefault).toBe(false);
|
|
@@ -93,7 +93,12 @@ let folderId: string;
|
|
|
93
93
|
|
|
94
94
|
beforeAll(async () => {
|
|
95
95
|
stack = await setupTestStack({
|
|
96
|
-
features: [
|
|
96
|
+
features: [
|
|
97
|
+
createFoldersFeature({
|
|
98
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
99
|
+
}),
|
|
100
|
+
hostFixturesFeature,
|
|
101
|
+
],
|
|
97
102
|
});
|
|
98
103
|
await unsafeCreateEntityTable(stack.db, folderEntity);
|
|
99
104
|
await unsafeCreateEntityTable(stack.db, folderAssignmentEntity);
|
|
@@ -79,7 +79,12 @@ let folderId: string;
|
|
|
79
79
|
|
|
80
80
|
beforeAll(async () => {
|
|
81
81
|
stack = await setupTestStack({
|
|
82
|
-
features: [
|
|
82
|
+
features: [
|
|
83
|
+
createFoldersFeature({
|
|
84
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
85
|
+
}),
|
|
86
|
+
hostFixturesFeature,
|
|
87
|
+
],
|
|
83
88
|
});
|
|
84
89
|
await unsafeCreateEntityTable(stack.db, folderEntity);
|
|
85
90
|
await unsafeCreateEntityTable(stack.db, folderAssignmentEntity);
|
|
@@ -336,7 +336,12 @@ describe("folders integration — openToAll access model", () => {
|
|
|
336
336
|
|
|
337
337
|
beforeAll(async () => {
|
|
338
338
|
openStack = await setupTestStack({
|
|
339
|
-
features: [
|
|
339
|
+
features: [
|
|
340
|
+
createFoldersFeature({
|
|
341
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
342
|
+
}),
|
|
343
|
+
hostFixturesFeature,
|
|
344
|
+
],
|
|
340
345
|
});
|
|
341
346
|
await unsafeCreateEntityTable(openStack.db, folderEntity);
|
|
342
347
|
await unsafeCreateEntityTable(openStack.db, folderAssignmentEntity);
|
package/src/folders/feature.ts
CHANGED
|
@@ -47,7 +47,7 @@ function registerFolders(
|
|
|
47
47
|
parents: readonly string[] | undefined,
|
|
48
48
|
): void {
|
|
49
49
|
r.describe(
|
|
50
|
-
"Generic, host-agnostic hierarchical folders for any entity. Owns two event-sourced entities — the per-tenant `folder` tree (`read_folders`, self-referential via parentId) and SINGLE-membership `folder-assignment` rows keyed by (entityType, entityId) (`read_folder_assignments`) — so filing an entity adds NO column to the host and needs no relational pivot or JOIN. The folder catalog uses the generic entity handlers (create, update [= rename, optimistic-locked], delete, list, detail); set-folder puts/moves an entity into a folder (one folder per entity) and clear-folder unfiles it (both idempotent). Read which folder an entity is in, or which entities a folder holds, by listing `folder-assignment` filtered on `entityId` or `folderId`. Every path uses one access rule — adopt the host's model with createFoldersFeature({ access: { openToAll:
|
|
50
|
+
"Generic, host-agnostic hierarchical folders for any entity. Owns two event-sourced entities — the per-tenant `folder` tree (`read_folders`, self-referential via parentId) and SINGLE-membership `folder-assignment` rows keyed by (entityType, entityId) (`read_folder_assignments`) — so filing an entity adds NO column to the host and needs no relational pivot or JOIN. The folder catalog uses the generic entity handlers (create, update [= rename, optimistic-locked], delete, list, detail); set-folder puts/moves an entity into a folder (one folder per entity) and clear-folder unfiles it (both idempotent). Read which folder an entity is in, or which entities a folder holds, by listing `folder-assignment` filtered on `entityId` or `folderId`. Every path uses one access rule — adopt the host's model with createFoldersFeature({ access: { openToAll: { reason } } }) or pin roles. Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
|
|
51
51
|
);
|
|
52
52
|
r.uiHints({
|
|
53
53
|
displayLabel: "Folders",
|
|
@@ -113,7 +113,7 @@ export const foldersFeature = defineFeature(FOLDERS_FEATURE_NAME, (r) =>
|
|
|
113
113
|
|
|
114
114
|
export type FoldersFeatureOptions = {
|
|
115
115
|
/** Access rule for all folder write/read paths. Default { roles: ["TenantAdmin","TenantMember"] }.
|
|
116
|
-
* Adopt the host's model — e.g. { openToAll:
|
|
116
|
+
* Adopt the host's model — e.g. { openToAll: { reason: "..." } } when any authenticated
|
|
117
117
|
* tenant user may file entities, or { roles: ["Admin"] } for a custom role
|
|
118
118
|
* vocabulary. Takes precedence over `roles`. */
|
|
119
119
|
readonly access?: AccessRule;
|
|
@@ -22,7 +22,13 @@ export const FormDraftQueries = {
|
|
|
22
22
|
// Any authenticated tenant user may save/discard/read their OWN draft — the
|
|
23
23
|
// per-row ownerId check in the handlers (not roles) is what keeps a draft
|
|
24
24
|
// private to the user who created it. See handlers/*.ts.
|
|
25
|
-
export const FORM_DRAFT_ACCESS: AccessRule = {
|
|
25
|
+
export const FORM_DRAFT_ACCESS: AccessRule = {
|
|
26
|
+
openToAll: {
|
|
27
|
+
reason:
|
|
28
|
+
"any signed-in tenant user may save, discard or read a draft; the per-row " +
|
|
29
|
+
"ownerId check in the handlers, not roles, keeps a draft private to its owner",
|
|
30
|
+
},
|
|
31
|
+
};
|
|
26
32
|
|
|
27
33
|
// Unique index name — shared between entity.ts (index declaration) and
|
|
28
34
|
// handlers/save.write.ts (unique-violation race detection).
|
|
@@ -81,7 +81,12 @@ let stack: TestStack;
|
|
|
81
81
|
beforeAll(async () => {
|
|
82
82
|
stack = await setupTestStack({
|
|
83
83
|
// Deliberately no `ownership` — the gate must hold on a bare mount.
|
|
84
|
-
features: [
|
|
84
|
+
features: [
|
|
85
|
+
createNotesHistoryFeature({
|
|
86
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
87
|
+
}),
|
|
88
|
+
hostFixturesFeature,
|
|
89
|
+
],
|
|
85
90
|
});
|
|
86
91
|
await unsafeCreateEntityTable(stack.db, noteEntryEntity);
|
|
87
92
|
await unsafeCreateEntityTable(stack.db, noteMentionEntity);
|
|
@@ -192,7 +197,10 @@ describe("notes-history read-gate — parents allowlist narrows both paths", ()
|
|
|
192
197
|
beforeAll(async () => {
|
|
193
198
|
allowStack = await setupTestStack({
|
|
194
199
|
features: [
|
|
195
|
-
createNotesHistoryFeature({
|
|
200
|
+
createNotesHistoryFeature({
|
|
201
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
202
|
+
parents: ["project"],
|
|
203
|
+
}),
|
|
196
204
|
fixtures,
|
|
197
205
|
],
|
|
198
206
|
});
|
|
@@ -33,7 +33,7 @@ function registerNotesHistory(
|
|
|
33
33
|
parents: readonly string[] | undefined,
|
|
34
34
|
): void {
|
|
35
35
|
r.describe(
|
|
36
|
-
"Generic, host-agnostic, append-only note history for any entity. Owns one event-sourced entity, `note-entry` (`read_note_entries`), keyed by (entityType, entityId) — so attaching notes adds NO column to the host entity and needs no relational pivot or JOIN. Provides a `create` write-handler (author stamped server-side from the caller, never client-supplied) and a `list` query filterable on entityId. Deliberately append-only: no update or delete handler is registered — a correction is a new entry, not an edit, so who-said-what-when stays reconstructable. Every path uses one access rule — adopt the host's model with createNotesHistoryFeature({ access: { openToAll:
|
|
36
|
+
"Generic, host-agnostic, append-only note history for any entity. Owns one event-sourced entity, `note-entry` (`read_note_entries`), keyed by (entityType, entityId) — so attaching notes adds NO column to the host entity and needs no relational pivot or JOIN. Provides a `create` write-handler (author stamped server-side from the caller, never client-supplied) and a `list` query filterable on entityId. Deliberately append-only: no update or delete handler is registered — a correction is a new entry, not an edit, so who-said-what-when stays reconstructable. Every path uses one access rule — adopt the host's model with createNotesHistoryFeature({ access: { openToAll: { reason } } }) or pin roles with createNotesHistoryFeature({ roles }).",
|
|
37
37
|
);
|
|
38
38
|
r.uiHints({
|
|
39
39
|
displayLabel: "Notes",
|
|
@@ -68,7 +68,7 @@ export const notesHistoryFeature = defineFeature(NOTES_HISTORY_FEATURE_NAME, (r)
|
|
|
68
68
|
|
|
69
69
|
export type NotesHistoryFeatureOptions = {
|
|
70
70
|
/** Access rule for the create/list paths. Default { roles: ["TenantAdmin","TenantMember"] }.
|
|
71
|
-
* Adopt the host's model — e.g. { openToAll:
|
|
71
|
+
* Adopt the host's model — e.g. { openToAll: { reason: "..." } } when the host lets any
|
|
72
72
|
* authenticated tenant user write (like the rest of its handlers), or
|
|
73
73
|
* { roles: ["Admin"] } for a custom role vocabulary. Takes precedence over `roles`. */
|
|
74
74
|
readonly access?: AccessRule;
|
|
@@ -10,7 +10,13 @@ export function buildAvailableScopesQuery(scopes: PatScopeConfig) {
|
|
|
10
10
|
return defineQueryHandler({
|
|
11
11
|
name: "available-scopes",
|
|
12
12
|
schema: z.object({}),
|
|
13
|
-
access: {
|
|
13
|
+
access: {
|
|
14
|
+
openToAll: {
|
|
15
|
+
reason:
|
|
16
|
+
"any signed-in user may list the deployment-wide PAT scope domains; the data " +
|
|
17
|
+
"is static configuration, not per-user or per-tenant",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
14
20
|
description:
|
|
15
21
|
"Lists the API scope domains this deployment declares, each with its display label and whether it offers write access, for choosing what a new personal access token may do.",
|
|
16
22
|
handler: async () =>
|
|
@@ -57,7 +57,13 @@ export function createPatCreateHandler(opts: CreatePatOptions = {}) {
|
|
|
57
57
|
currentPassword: z.string().min(1),
|
|
58
58
|
mfaCode: z.string().optional(),
|
|
59
59
|
}),
|
|
60
|
-
access: {
|
|
60
|
+
access: {
|
|
61
|
+
openToAll: {
|
|
62
|
+
reason:
|
|
63
|
+
"each signed-in user may mint a personal access token for their own account " +
|
|
64
|
+
"only, after re-verifying their own password (and MFA code, if enrolled)",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
61
67
|
escapeHatch: {
|
|
62
68
|
reason:
|
|
63
69
|
"Reads the caller's own passwordHash (privileged-only field) via ctx.queryAs(SYSTEM, " +
|
|
@@ -46,7 +46,13 @@ export const listPatQuery = definePagedQueryHandler({
|
|
|
46
46
|
sort: z.string().optional(),
|
|
47
47
|
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
48
48
|
}),
|
|
49
|
-
access: {
|
|
49
|
+
access: {
|
|
50
|
+
openToAll: {
|
|
51
|
+
reason:
|
|
52
|
+
"each signed-in user lists only their own personal access tokens; the query " +
|
|
53
|
+
"filters apiTokenTable by the caller's own userId",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
50
56
|
description:
|
|
51
57
|
"Lists the calling user's personal access tokens with metadata only (name, key prefix, scopes, computed status, created/expiry/revoked timestamps, including revoked ones) and never the token secret.",
|
|
52
58
|
outputSchema: z.object({
|
|
@@ -13,7 +13,13 @@ import { apiTokenTable } from "../schema/api-token";
|
|
|
13
13
|
export const revokePatWrite = defineWriteHandler({
|
|
14
14
|
name: "revoke",
|
|
15
15
|
schema: z.object({ id: z.uuid() }),
|
|
16
|
-
access: {
|
|
16
|
+
access: {
|
|
17
|
+
openToAll: {
|
|
18
|
+
reason:
|
|
19
|
+
"each signed-in user revokes only their own personal access token; ownership " +
|
|
20
|
+
"is enforced in the WHERE (userId = caller)",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
17
23
|
description:
|
|
18
24
|
"Permanently revokes one of the caller's own personal access tokens so it stops authenticating; use it when a token leaked or is no longer needed.",
|
|
19
25
|
agent: { risk: "high" },
|
|
@@ -6,6 +6,11 @@ import {
|
|
|
6
6
|
import { PAT_MINT_SCREEN_ID, PAT_SCREEN_ID, PatHandlers, PatQueries } from "./constants";
|
|
7
7
|
import type { PatScopeConfig } from "./scopes";
|
|
8
8
|
|
|
9
|
+
const PAT_LIST_OPEN_REASON =
|
|
10
|
+
"each signed-in user views only their own personal access tokens, mirroring the list handler";
|
|
11
|
+
const PAT_MINT_OPEN_REASON =
|
|
12
|
+
"each signed-in user mints a token for their own account only, mirroring the create handler";
|
|
13
|
+
|
|
9
14
|
export const patListScreen: ProjectionListScreenDefinition = {
|
|
10
15
|
id: PAT_SCREEN_ID,
|
|
11
16
|
type: "projectionList",
|
|
@@ -47,7 +52,11 @@ export const patListScreen: ProjectionListScreenDefinition = {
|
|
|
47
52
|
style: "primary",
|
|
48
53
|
},
|
|
49
54
|
],
|
|
50
|
-
access: {
|
|
55
|
+
access: {
|
|
56
|
+
openToAll: {
|
|
57
|
+
reason: PAT_LIST_OPEN_REASON,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
51
60
|
};
|
|
52
61
|
|
|
53
62
|
// Grant-string vocabulary for a scope config's multiSelect field — a
|
|
@@ -102,6 +111,10 @@ export function createPatMintScreen(scopes: PatScopeConfig): SecretMintScreenDef
|
|
|
102
111
|
},
|
|
103
112
|
redirect: PAT_SCREEN_ID,
|
|
104
113
|
cancelTarget: PAT_SCREEN_ID,
|
|
105
|
-
access: {
|
|
114
|
+
access: {
|
|
115
|
+
openToAll: {
|
|
116
|
+
reason: PAT_MINT_OPEN_REASON,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
106
119
|
};
|
|
107
120
|
}
|
|
@@ -7,12 +7,19 @@ describe("createSecretsFeature access/roles precedence", () => {
|
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
test("access-only is accepted", () => {
|
|
10
|
-
expect(() =>
|
|
10
|
+
expect(() =>
|
|
11
|
+
createSecretsFeature({
|
|
12
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
13
|
+
}),
|
|
14
|
+
).not.toThrow();
|
|
11
15
|
});
|
|
12
16
|
|
|
13
17
|
test("access + roles together fail fast", () => {
|
|
14
|
-
expect(() =>
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
expect(() =>
|
|
19
|
+
createSecretsFeature({
|
|
20
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
21
|
+
roles: ["Admin"],
|
|
22
|
+
}),
|
|
23
|
+
).toThrow(/either `access` or `roles`/);
|
|
17
24
|
});
|
|
18
25
|
});
|
|
@@ -100,7 +100,11 @@ describe("secrets — access: { openToAll: true } (#2296)", () => {
|
|
|
100
100
|
let stack: TestStack;
|
|
101
101
|
|
|
102
102
|
beforeAll(async () => {
|
|
103
|
-
stack = await buildStack(
|
|
103
|
+
stack = await buildStack(
|
|
104
|
+
createSecretsFeature({
|
|
105
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
104
108
|
});
|
|
105
109
|
|
|
106
110
|
afterAll(async () => {
|
package/src/secrets/feature.ts
CHANGED
|
@@ -93,7 +93,7 @@ export function requireSecretsContext(
|
|
|
93
93
|
export type SecretsFeatureOptions = {
|
|
94
94
|
/** Access rule for the `set`, `delete` and `list` handlers. Default
|
|
95
95
|
* { roles: ["TenantAdmin"] }. Adopt the host's role vocabulary with
|
|
96
|
-
* { roles: [...] }, or { openToAll:
|
|
96
|
+
* { roles: [...] }, or { openToAll: { reason: "..." } } to let any authenticated tenant
|
|
97
97
|
* user manage secrets — note this is a much larger blast radius than the
|
|
98
98
|
* default: tenant credentials (API keys, tokens) become writable/deletable,
|
|
99
99
|
* and their previews/hints listable, by every tenant member, not just
|
|
@@ -121,7 +121,7 @@ export function createSecretsFeature(opts: SecretsFeatureOptions = {}): FeatureD
|
|
|
121
121
|
const access = resolveSecretsAccess(opts);
|
|
122
122
|
return defineFeature(SECRETS_FEATURE_NAME, (r) => {
|
|
123
123
|
r.describe(
|
|
124
|
-
'Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: ["TenantAdmin"] }; adopt the host\'s role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll:
|
|
124
|
+
'Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: ["TenantAdmin"] }; adopt the host\'s role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: { reason } } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins).',
|
|
125
125
|
);
|
|
126
126
|
r.uiHints({
|
|
127
127
|
displayLabel: "Tenant Secrets",
|
|
@@ -12,7 +12,13 @@ import { userSessionTable } from "../schema/user-session";
|
|
|
12
12
|
export const mineQuery = definePagedQueryHandler({
|
|
13
13
|
name: "user-session:mine",
|
|
14
14
|
schema: z.object({}),
|
|
15
|
-
access: {
|
|
15
|
+
access: {
|
|
16
|
+
openToAll: {
|
|
17
|
+
reason:
|
|
18
|
+
"each signed-in user lists only their own live sessions; the query filters " +
|
|
19
|
+
"userSessionTable by the caller's own userId",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
16
22
|
description:
|
|
17
23
|
"Lists the calling user's own still-live sessions, newest first, each flagged whether it is the one making the request; use it to show a user their signed-in devices.",
|
|
18
24
|
outputSchema: z.object({
|
|
@@ -15,7 +15,13 @@ import { SESSION_REVOKED_AGGREGATE_TYPE, SESSION_REVOKED_EVENT_QN } from "../ses
|
|
|
15
15
|
export const revokeAllOthersWrite = defineWriteHandler({
|
|
16
16
|
name: "user-session:revoke-all-others",
|
|
17
17
|
schema: z.object({}),
|
|
18
|
-
access: {
|
|
18
|
+
access: {
|
|
19
|
+
openToAll: {
|
|
20
|
+
reason:
|
|
21
|
+
"each signed-in user revokes only their own other sessions; the update is " +
|
|
22
|
+
"scoped to the caller's own userId, keeping only their current session (sid)",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
19
25
|
description:
|
|
20
26
|
'Irreversibly signs the calling user out of every session except the one making the request and reports how many were dropped; use it for a "sign out everywhere else" action after a suspected compromise.',
|
|
21
27
|
agent: { risk: "high" },
|
|
@@ -28,7 +28,13 @@ export const revokeWrite = defineWriteHandler({
|
|
|
28
28
|
schema: z.object({
|
|
29
29
|
id: z.uuid(),
|
|
30
30
|
}),
|
|
31
|
-
access: {
|
|
31
|
+
access: {
|
|
32
|
+
openToAll: {
|
|
33
|
+
reason:
|
|
34
|
+
"each signed-in user revokes only their own session by id; the update is " +
|
|
35
|
+
"scoped to the caller's own userId",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
32
38
|
description:
|
|
33
39
|
"Irreversibly signs one of the caller's own sessions out by id; use it when a user wants to drop a single device, and note that revoking their current session logs them out.",
|
|
34
40
|
agent: { risk: "high" },
|
package/src/sessions/screens.ts
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
} from "./constants";
|
|
14
14
|
|
|
15
15
|
const listAccess = { roles: access.admin };
|
|
16
|
+
const SESSION_MINE_OPEN_REASON =
|
|
17
|
+
"each signed-in user views and revokes only their own sessions, mirroring the mine/revoke handlers";
|
|
16
18
|
|
|
17
19
|
export const sessionListScreen: ProjectionListScreenDefinition = {
|
|
18
20
|
id: SESSION_LIST_SCREEN_ID,
|
|
@@ -140,5 +142,9 @@ export const sessionMineScreen: ProjectionListScreenDefinition = {
|
|
|
140
142
|
confirm: i18nKey("sessions.mine.revokeAllOthers.confirm"),
|
|
141
143
|
},
|
|
142
144
|
],
|
|
143
|
-
access: {
|
|
145
|
+
access: {
|
|
146
|
+
openToAll: {
|
|
147
|
+
reason: SESSION_MINE_OPEN_REASON,
|
|
148
|
+
},
|
|
149
|
+
},
|
|
144
150
|
};
|
|
@@ -105,7 +105,9 @@ describe("createTagsFeature access-options", () => {
|
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
test("access:{openToAll} applies to every write- and query-path", () => {
|
|
108
|
-
const feature = createTagsFeature({
|
|
108
|
+
const feature = createTagsFeature({
|
|
109
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
110
|
+
});
|
|
109
111
|
for (const path of [
|
|
110
112
|
"create-tag",
|
|
111
113
|
"update-tag",
|
|
@@ -116,17 +118,28 @@ describe("createTagsFeature access-options", () => {
|
|
|
116
118
|
"assign-tag",
|
|
117
119
|
"remove-tag",
|
|
118
120
|
]) {
|
|
119
|
-
expect(rawWriteAccess(feature, path)).toEqual({
|
|
121
|
+
expect(rawWriteAccess(feature, path)).toEqual({
|
|
122
|
+
openToAll: { reason: "test handler callable by any signed-in test user" },
|
|
123
|
+
});
|
|
120
124
|
}
|
|
121
125
|
for (const query of ["tag:list", "tag:detail", "tag-assignment:list"]) {
|
|
122
|
-
expect(rawQueryAccess(feature, query)).toEqual({
|
|
126
|
+
expect(rawQueryAccess(feature, query)).toEqual({
|
|
127
|
+
openToAll: { reason: "test handler callable by any signed-in test user" },
|
|
128
|
+
});
|
|
123
129
|
}
|
|
124
130
|
});
|
|
125
131
|
|
|
126
132
|
test("access takes precedence over the roles shorthand", () => {
|
|
127
|
-
const feature = createTagsFeature({
|
|
128
|
-
|
|
129
|
-
|
|
133
|
+
const feature = createTagsFeature({
|
|
134
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
135
|
+
roles: ["Admin"],
|
|
136
|
+
});
|
|
137
|
+
expect(rawWriteAccess(feature, "create-tag")).toEqual({
|
|
138
|
+
openToAll: { reason: "test handler callable by any signed-in test user" },
|
|
139
|
+
});
|
|
140
|
+
expect(rawQueryAccess(feature, "tag:list")).toEqual({
|
|
141
|
+
openToAll: { reason: "test handler callable by any signed-in test user" },
|
|
142
|
+
});
|
|
130
143
|
});
|
|
131
144
|
|
|
132
145
|
test("access:{roles} threads through like the roles shorthand", () => {
|
|
@@ -139,12 +152,16 @@ describe("createTagsFeature access-options", () => {
|
|
|
139
152
|
describe("createTagsFeature toggleable-option (tier-gating)", () => {
|
|
140
153
|
test("without toggleable: feature is always-on (toggleableDefault undefined)", () => {
|
|
141
154
|
expect(createTagsFeature().toggleableDefault).toBeUndefined();
|
|
142
|
-
expect(
|
|
155
|
+
expect(
|
|
156
|
+
createTagsFeature({
|
|
157
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
158
|
+
}).toggleableDefault,
|
|
159
|
+
).toBeUndefined();
|
|
143
160
|
});
|
|
144
161
|
|
|
145
162
|
test("toggleable:{default:false} makes the feature tier-gatable, fail-closed", () => {
|
|
146
163
|
const feature = createTagsFeature({
|
|
147
|
-
access: { openToAll:
|
|
164
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
148
165
|
toggleable: { default: false },
|
|
149
166
|
});
|
|
150
167
|
expect(feature.toggleableDefault).toBe(false);
|
|
@@ -93,7 +93,12 @@ let tagId: string;
|
|
|
93
93
|
|
|
94
94
|
beforeAll(async () => {
|
|
95
95
|
stack = await setupTestStack({
|
|
96
|
-
features: [
|
|
96
|
+
features: [
|
|
97
|
+
createTagsFeature({
|
|
98
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
99
|
+
}),
|
|
100
|
+
hostFixturesFeature,
|
|
101
|
+
],
|
|
97
102
|
});
|
|
98
103
|
await unsafeCreateEntityTable(stack.db, tagEntity);
|
|
99
104
|
await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
|
|
@@ -99,7 +99,12 @@ let tagId: string;
|
|
|
99
99
|
beforeAll(async () => {
|
|
100
100
|
stack = await setupTestStack({
|
|
101
101
|
// Deliberately no `ownership` — the gate must hold on a bare mount.
|
|
102
|
-
features: [
|
|
102
|
+
features: [
|
|
103
|
+
createTagsFeature({
|
|
104
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
105
|
+
}),
|
|
106
|
+
hostFixturesFeature,
|
|
107
|
+
],
|
|
103
108
|
});
|
|
104
109
|
await unsafeCreateEntityTable(stack.db, tagEntity);
|
|
105
110
|
await unsafeCreateEntityTable(stack.db, tagAssignmentEntity);
|
|
@@ -303,7 +308,7 @@ describe("tags read-gate — ownership narrows further, it does not replace the
|
|
|
303
308
|
ownedStack = await setupTestStack({
|
|
304
309
|
features: [
|
|
305
310
|
createTagsFeature({
|
|
306
|
-
access: { openToAll:
|
|
311
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
307
312
|
// An extra row rule on the assignment row itself, unrelated to who
|
|
308
313
|
// may see the host: the caller's claim must name the row's host type.
|
|
309
314
|
ownership: {
|
|
@@ -457,7 +457,12 @@ describe("tags integration — openToAll access model", () => {
|
|
|
457
457
|
|
|
458
458
|
beforeAll(async () => {
|
|
459
459
|
openStack = await setupTestStack({
|
|
460
|
-
features: [
|
|
460
|
+
features: [
|
|
461
|
+
createTagsFeature({
|
|
462
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
463
|
+
}),
|
|
464
|
+
hostFixturesFeature,
|
|
465
|
+
],
|
|
461
466
|
});
|
|
462
467
|
await unsafeCreateEntityTable(openStack.db, tagEntity);
|
|
463
468
|
await unsafeCreateEntityTable(openStack.db, tagAssignmentEntity);
|
package/src/tags/constants.ts
CHANGED
|
@@ -55,7 +55,7 @@ export const TagsQueries = {
|
|
|
55
55
|
export const DEFAULT_TAG_ROLES = ["TenantAdmin", "TenantMember"] as const;
|
|
56
56
|
|
|
57
57
|
// The default access rule applied to every tag handler when the app passes
|
|
58
|
-
// neither `access` nor `roles`. createTagsFeature({ access: { openToAll:
|
|
58
|
+
// neither `access` nor `roles`. createTagsFeature({ access: { openToAll: { reason } } })
|
|
59
59
|
// makes tagging reachable for any authenticated tenant user — matching apps
|
|
60
60
|
// whose other handlers are openToAll rather than role-gated.
|
|
61
61
|
export const DEFAULT_TAG_ACCESS: AccessRule = { roles: DEFAULT_TAG_ROLES };
|
package/src/tags/feature.ts
CHANGED
|
@@ -54,7 +54,7 @@ function registerTags(
|
|
|
54
54
|
parents: readonly string[] | undefined,
|
|
55
55
|
): void {
|
|
56
56
|
r.describe(
|
|
57
|
-
"Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Catalog screens are declarative (`entityList` + `entityEdit`) and use convention QNs `tag:{create,update,delete}`; TagManager/TagPicker keep `create-tag`/`update-tag`/`delete-tag`. Also: `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. A tag with empty `scope` is global; a `scope` of an entityType restricts it to that type in the picker. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll:
|
|
57
|
+
"Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Catalog screens are declarative (`entityList` + `entityEdit`) and use convention QNs `tag:{create,update,delete}`; TagManager/TagPicker keep `create-tag`/`update-tag`/`delete-tag`. Also: `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. A tag with empty `scope` is global; a `scope` of an entityType restricts it to that type in the picker. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: { reason } } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
|
|
58
58
|
);
|
|
59
59
|
r.uiHints({
|
|
60
60
|
displayLabel: "Tags",
|
|
@@ -134,7 +134,7 @@ export const tagsFeature = defineFeature(TAGS_FEATURE_NAME, (r) =>
|
|
|
134
134
|
|
|
135
135
|
export type TagsFeatureOptions = {
|
|
136
136
|
/** Access rule for all tag write/read paths. Default { roles: ["TenantAdmin","TenantMember"] }.
|
|
137
|
-
* Adopt the host's model — e.g. { openToAll:
|
|
137
|
+
* Adopt the host's model — e.g. { openToAll: { reason: "..." } } when the host lets any
|
|
138
138
|
* authenticated tenant user tag (like the rest of its handlers), or
|
|
139
139
|
* { roles: ["Admin"] } for a custom role vocabulary. Takes precedence over `roles`. */
|
|
140
140
|
readonly access?: AccessRule;
|
|
@@ -25,7 +25,7 @@ const probeFeature = defineFeature("tz-probe", (r) => {
|
|
|
25
25
|
"read-tz",
|
|
26
26
|
z.object({}),
|
|
27
27
|
async (_event, ctx) => ({ isSuccess: true, data: { tenant: ctx.tz.tenant } }),
|
|
28
|
-
{ access: { openToAll:
|
|
28
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
29
29
|
);
|
|
30
30
|
});
|
|
31
31
|
|
|
@@ -35,7 +35,7 @@ const probeFeature = defineFeature("tz-probe", (r) => {
|
|
|
35
35
|
const queryProbeFeature = defineFeature("tz-cache-probe", (r) => {
|
|
36
36
|
r.requires("tenant");
|
|
37
37
|
r.queryHandler("read-tz", z.object({}), async (_query, ctx) => ({ tenant: ctx.tz.tenant }), {
|
|
38
|
-
access: { openToAll:
|
|
38
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
39
39
|
});
|
|
40
40
|
});
|
|
41
41
|
|
|
@@ -401,7 +401,11 @@ describe("scenario 7: access rules on handlers", () => {
|
|
|
401
401
|
"SystemAdmin",
|
|
402
402
|
]);
|
|
403
403
|
expect(stack.registry.getQueryHandler(TenantQueries.me)?.access).toEqual({
|
|
404
|
-
openToAll:
|
|
404
|
+
openToAll: {
|
|
405
|
+
reason:
|
|
406
|
+
"any signed-in user reads only their own active tenant; the query is scoped " +
|
|
407
|
+
"to the caller's own tenantId",
|
|
408
|
+
},
|
|
405
409
|
});
|
|
406
410
|
});
|
|
407
411
|
});
|
|
@@ -9,7 +9,13 @@ import { tenantTable } from "../schema/tenant";
|
|
|
9
9
|
export const meQuery = defineQueryHandler({
|
|
10
10
|
name: "me",
|
|
11
11
|
schema: z.object({}),
|
|
12
|
-
access: {
|
|
12
|
+
access: {
|
|
13
|
+
openToAll: {
|
|
14
|
+
reason:
|
|
15
|
+
"any signed-in user reads only their own active tenant; the query is scoped " +
|
|
16
|
+
"to the caller's own tenantId",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
13
19
|
description:
|
|
14
20
|
"Returns the record of the tenant the caller is currently signed in to, or null if it is gone; use it whenever the active tenant's own name, key or settings are needed.",
|
|
15
21
|
handler: async (query, ctx) => {
|
|
@@ -11,7 +11,13 @@ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user
|
|
|
11
11
|
export const meQuery = defineQueryHandler({
|
|
12
12
|
name: "user:me",
|
|
13
13
|
schema: z.object({}),
|
|
14
|
-
access: {
|
|
14
|
+
access: {
|
|
15
|
+
openToAll: {
|
|
16
|
+
reason:
|
|
17
|
+
"each signed-in user reads only their own identity record; the detail lookup " +
|
|
18
|
+
"is by the caller's own id",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
15
21
|
description:
|
|
16
22
|
"Returns the signed-in caller's own identity record, with the password hash stripped by field-level read access; use it whenever the current user's own profile data is needed.",
|
|
17
23
|
handler: async (query, ctx) => {
|