@cosmicdrift/kumiko-bundled-features 0.336.0 → 0.337.1
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/dist/agent-tools/agent-manifest.js +34 -7
- package/dist/agent-tools/tool-catalog.js +21 -2
- package/dist/audit/handlers/list.query.js +7 -3
- package/dist/auth-email-password/handlers/login.write.js +1 -2
- package/dist/auth-email-password/seeding.js +6 -0
- package/dist/auth-email-password/web/invite-accept-screen.js +4 -1
- package/dist/auth-mfa/handlers/enable-start.write.js +13 -2
- package/dist/auth-mfa/hmac-token-codec.js +8 -9
- package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +2 -0
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +2 -7
- package/dist/config/write-helpers.js +2 -2
- package/dist/custom-fields/feature.js +3 -2
- package/dist/custom-fields/lib/field-access.d.ts +3 -2
- package/dist/custom-fields/lib/field-access.js +1 -0
- package/dist/delivery/unsubscribe.js +1 -0
- package/dist/files-tenant-data/hooks.js +11 -3
- package/dist/folders/web/folder-section.js +8 -1
- package/dist/inbound-mail-foundation/feature.js +1 -1
- package/dist/jobs/constants.d.ts +1 -0
- package/dist/jobs/constants.js +1 -0
- package/dist/jobs/handlers/trigger.write.js +7 -2
- package/dist/jobs/i18n.js +3 -0
- package/dist/metrics/compute.js +16 -5
- package/dist/notes-history/feature.js +2 -2
- package/dist/notes-history/handlers/add-note.write.js +2 -1
- package/dist/notes-history/web/notes-section.js +6 -1
- package/dist/personal-access-tokens/schema/api-token.js +5 -5
- package/dist/rate-limiting/handlers/bucket-access.d.ts +2 -5
- package/dist/secrets/feature.js +2 -0
- package/dist/secrets/handlers/delete.write.js +4 -0
- package/dist/secrets/handlers/set.write.js +5 -2
- package/dist/secrets/i18n.d.ts +10 -0
- package/dist/secrets/i18n.js +10 -0
- package/dist/secrets/write-gate.d.ts +10 -0
- package/dist/secrets/write-gate.js +36 -0
- package/dist/shared/index.d.ts +0 -1
- package/dist/shared/index.js +0 -1
- package/dist/shared/session-locale-field.js +1 -2
- package/dist/shared/session-timezone-field.js +3 -2
- package/dist/step-dispatcher/feature.js +4 -3
- package/dist/step-dispatcher/mail-runner.d.ts +1 -0
- package/dist/step-dispatcher/mail-runner.js +5 -0
- package/dist/step-dispatcher/webhook-runner.d.ts +7 -0
- package/dist/step-dispatcher/webhook-runner.js +9 -0
- package/dist/subscription-mollie/plugin-methods.js +3 -5
- package/dist/subscription-stripe/plugin-methods.js +30 -5
- package/dist/tenant/handlers/member-directory.query.js +16 -3
- package/dist/tenant/handlers/tenant-directory.query.js +7 -1
- package/dist/tenant-handover/feature.js +1 -1
- package/dist/tenant-handover/move-entity-graph.js +11 -3
- package/dist/tenant-lifecycle/lifecycle-gate.d.ts +4 -0
- package/dist/tenant-lifecycle/lifecycle-gate.js +26 -4
- package/dist/user/schema/user.js +4 -3
- package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +3 -3
- package/dist/user-data-rights/handlers/deletion-grace-period.js +3 -1
- package/dist/user-data-rights/handlers/request-deletion.write.js +3 -3
- package/dist/workflow-runner/db/queries/due-runs.d.ts +1 -0
- package/dist/workflow-runner/db/queries/due-runs.js +4 -1
- package/package.json +9 -9
- package/src/agent-tools/changes.json +6 -0
- package/src/secrets/changes.json +7 -0
- package/src/subscription-stripe/changes.json +12 -0
- package/src/tenant/changes.json +6 -0
- package/src/tenant-lifecycle/changes.json +8 -1
- package/src/workflow-runner/changes.json +6 -0
- package/dist/shared/session-field.d.ts +0 -3
- package/dist/shared/session-field.js +0 -6
|
@@ -6,14 +6,38 @@ import * as z from "zod";
|
|
|
6
6
|
function entryAsLabels(entry) {
|
|
7
7
|
return Object.fromEntries(Object.entries(entry).map(([locale, value]) => [locale, translationValueOtherText(value)]));
|
|
8
8
|
}
|
|
9
|
+
const labelsIndexByTranslations = new WeakMap();
|
|
10
|
+
/** Every tail starting at a key boundary (`:` or `.`, with and without the
|
|
11
|
+
* separator) maps to the labels of the first key that ends with it. */
|
|
12
|
+
function buildSuffixIndex(translations) {
|
|
13
|
+
const index = new Map();
|
|
14
|
+
for (const [key, entry] of Object.entries(translations)) {
|
|
15
|
+
const labels = entryAsLabels(entry);
|
|
16
|
+
const addTail = (start) => {
|
|
17
|
+
const tail = key.slice(start);
|
|
18
|
+
if (!index.has(tail))
|
|
19
|
+
index.set(tail, labels);
|
|
20
|
+
};
|
|
21
|
+
addTail(0);
|
|
22
|
+
for (let position = 0; position < key.length; position++) {
|
|
23
|
+
if (key[position] === ":" || key[position] === ".") {
|
|
24
|
+
addTail(position);
|
|
25
|
+
addTail(position + 1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return index;
|
|
30
|
+
}
|
|
9
31
|
/** Translation keys carry a feature prefix the manifest can't reconstruct
|
|
10
|
-
* (`showcase:entity:item:field:title`), so match on the suffix instead.
|
|
32
|
+
* (`showcase:entity:item:field:title`), so match on the suffix instead. The
|
|
33
|
+
* suffix must start at a `:` or `.` boundary of the key. */
|
|
11
34
|
function labelsForSuffix(translations, suffix) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
35
|
+
let index = labelsIndexByTranslations.get(translations);
|
|
36
|
+
if (index === undefined) {
|
|
37
|
+
index = buildSuffixIndex(translations);
|
|
38
|
+
labelsIndexByTranslations.set(translations, index);
|
|
15
39
|
}
|
|
16
|
-
return {};
|
|
40
|
+
return index.get(suffix) ?? {};
|
|
17
41
|
}
|
|
18
42
|
/** Nav/screen/workspace gating is opt-in: no rule means visible to everyone
|
|
19
43
|
* (NavDefinition.access). Handlers are the opposite — `hasAccess` is
|
|
@@ -205,6 +229,9 @@ function lastSegment(qualifiedId) {
|
|
|
205
229
|
const idx = qualifiedId.lastIndexOf(":");
|
|
206
230
|
return idx === -1 ? qualifiedId : qualifiedId.slice(idx + 1);
|
|
207
231
|
}
|
|
232
|
+
function detailIdParam(screen) {
|
|
233
|
+
return screen.type === "projectionDetail" ? (screen.idParam ?? "id") : "id";
|
|
234
|
+
}
|
|
208
235
|
function buildScreens(screens, navs, workspaces, translations, roles, visibleNavIds) {
|
|
209
236
|
const allNavs = [...navs.values()];
|
|
210
237
|
const result = [];
|
|
@@ -239,8 +266,8 @@ function buildScreens(screens, navs, workspaces, translations, roles, visibleNav
|
|
|
239
266
|
description: labelsForSuffix(translations, screen.description)["en"] ?? screen.description,
|
|
240
267
|
}),
|
|
241
268
|
...("entity" in screen && screen.entity !== undefined && { entity: screen.entity }),
|
|
242
|
-
// A detail screen takes the row id; everything else is parameterless.
|
|
243
|
-
params: screen.detailFor !== undefined ? [
|
|
269
|
+
// A detail screen takes the row id under its idParam; everything else is parameterless.
|
|
270
|
+
params: screen.detailFor !== undefined ? [detailIdParam(screen)] : [],
|
|
244
271
|
workspaces: screenWorkspaces,
|
|
245
272
|
...(screen.detailFor !== undefined && { detailFor: screen.detailFor }),
|
|
246
273
|
...("handler" in screen && typeof screen.handler === "string" && { handler: screen.handler }),
|
|
@@ -206,6 +206,25 @@ export function toolNameForQn(qn) {
|
|
|
206
206
|
const sanitized = joined.replace(/[^A-Za-z0-9_]/g, "_").replace(/_+/g, "_");
|
|
207
207
|
return sanitized.slice(0, MAX_TOOL_NAME_LENGTH);
|
|
208
208
|
}
|
|
209
|
+
// FNV-1a: only needs to be stable and well-spread, not collision-proof — a hash clash
|
|
210
|
+
// on top of a name clash falls through to the sink's drop-later-tool guard.
|
|
211
|
+
function shortQnHash(qn) {
|
|
212
|
+
let hash = 0x811c9dc5;
|
|
213
|
+
for (let index = 0; index < qn.length; index++) {
|
|
214
|
+
hash = Math.imul(hash ^ qn.charCodeAt(index), 0x01000193) >>> 0;
|
|
215
|
+
}
|
|
216
|
+
return hash.toString(16).padStart(8, "0");
|
|
217
|
+
}
|
|
218
|
+
/** Sanitizing, verb-dropping and truncation make distinct QNs share a tool name; the
|
|
219
|
+
* first QN in manifest order keeps the plain name, later ones get a QN-hash suffix so
|
|
220
|
+
* no handler silently disappears from the catalog. */
|
|
221
|
+
function uniqueToolNameForQn(qn, usedNames) {
|
|
222
|
+
const plainName = toolNameForQn(qn);
|
|
223
|
+
if (!usedNames.has(plainName))
|
|
224
|
+
return plainName;
|
|
225
|
+
const hashSuffix = `_${shortQnHash(qn)}`;
|
|
226
|
+
return `${plainName.slice(0, MAX_TOOL_NAME_LENGTH - hashSuffix.length)}${hashSuffix}`;
|
|
227
|
+
}
|
|
209
228
|
export const OPEN_FORM_TOOL_NAME = "open_form";
|
|
210
229
|
function buildNavigateTool(manifest) {
|
|
211
230
|
const entityScreens = new Map();
|
|
@@ -359,7 +378,7 @@ function addQueryHandlerTools(manifest, entityListDetailQns, denyQns, sink) {
|
|
|
359
378
|
continue;
|
|
360
379
|
if (entityListDetailQns.has(handler.qn))
|
|
361
380
|
continue;
|
|
362
|
-
const name =
|
|
381
|
+
const name = uniqueToolNameForQn(handler.qn, sink.usedNames);
|
|
363
382
|
addTool(sink, { name, description: handlerDescription(handler), inputSchema: handler.inputSchema }, {
|
|
364
383
|
kind: "server",
|
|
365
384
|
op: "query",
|
|
@@ -375,7 +394,7 @@ function addWriteHandlerTools(manifest, detailQnByEntity, denyQns, sink) {
|
|
|
375
394
|
continue;
|
|
376
395
|
if (denyQns.has(handler.qn))
|
|
377
396
|
continue;
|
|
378
|
-
const name =
|
|
397
|
+
const name = uniqueToolNameForQn(handler.qn, sink.usedNames);
|
|
379
398
|
const detailQn = handler.entity !== undefined ? detailQnByEntity.get(handler.entity) : undefined;
|
|
380
399
|
const injectsVersion = detailQn !== undefined && schemaRequiresField(handler.inputSchema, "version");
|
|
381
400
|
const inputSchema = injectsVersion
|
|
@@ -27,15 +27,19 @@ function buildDateRange(from, to) {
|
|
|
27
27
|
range.lte = Temporal.Instant.from(to);
|
|
28
28
|
return range;
|
|
29
29
|
}
|
|
30
|
+
function escapeLikePattern(value) {
|
|
31
|
+
return value.replace(/[\\%_]/g, (m) => `\\${m}`);
|
|
32
|
+
}
|
|
30
33
|
function buildAuditWhere(tenantId, p) {
|
|
31
34
|
const where = { tenantId };
|
|
32
35
|
if (p.aggregateType)
|
|
33
36
|
where["aggregateType"] = p.aggregateType;
|
|
34
37
|
if (p.aggregateId)
|
|
35
38
|
where["aggregateId"] = p.aggregateId;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
if (p.eventType)
|
|
40
|
+
where["type"] = p.eventType;
|
|
41
|
+
else if (p.search)
|
|
42
|
+
where["type"] = { like: `%${escapeLikePattern(p.search)}%` };
|
|
39
43
|
if (p.userId)
|
|
40
44
|
where["createdBy"] = p.userId;
|
|
41
45
|
const range = buildDateRange(p.from, p.to);
|
|
@@ -179,8 +179,7 @@ export function createLoginHandler(opts = {}) {
|
|
|
179
179
|
const statusGate = gateEnforceAccountStatus(found);
|
|
180
180
|
if (!statusGate.ok)
|
|
181
181
|
return statusGate.result;
|
|
182
|
-
// Still needed for candidate ORDER (preferred tenant first)
|
|
183
|
-
// active/blocked/teardown decision comes from ctx.resolveActiveMembership below.
|
|
182
|
+
// Still needed for candidate ORDER (preferred tenant first) in gateResolveMembership.
|
|
184
183
|
const memberships = (await ctx.queryAs(systemUser, TENANT_MEMBERSHIPS_QUERY, {
|
|
185
184
|
userId: found.id,
|
|
186
185
|
})); // @cast-boundary db-runner
|
|
@@ -151,6 +151,12 @@ export async function seedAdmin(db, options) {
|
|
|
151
151
|
return { id: userId };
|
|
152
152
|
}
|
|
153
153
|
function compareInsertedAt(a, b) {
|
|
154
|
+
// selectMany<ActiveUserRow> is an unchecked row-shape claim. A codec that
|
|
155
|
+
// yields another type would make both comparisons false and silently fall
|
|
156
|
+
// back to id order, which can crown the younger duplicate as survivor.
|
|
157
|
+
if (typeof a.epochNanoseconds !== "bigint" || typeof b.epochNanoseconds !== "bigint") {
|
|
158
|
+
throw new Error("compareInsertedAt: user.insertedAt is not a Temporal.Instant");
|
|
159
|
+
}
|
|
154
160
|
if (a.epochNanoseconds < b.epochNanoseconds)
|
|
155
161
|
return -1;
|
|
156
162
|
if (a.epochNanoseconds > b.epochNanoseconds)
|
|
@@ -22,6 +22,9 @@ import { csrfHeader } from "./auth-client.js";
|
|
|
22
22
|
import { resolvePostAuthHref } from "./auth-form-logic.js";
|
|
23
23
|
import { AuthCard, useUrlToken } from "./auth-form-primitives.js";
|
|
24
24
|
import { SessionContext, UNAUTHENTICATED } from "./session.js";
|
|
25
|
+
function isMfaPending(data) {
|
|
26
|
+
return data.tenantId === undefined;
|
|
27
|
+
}
|
|
25
28
|
function grantedRoles(data) {
|
|
26
29
|
if (data.user?.roles !== undefined)
|
|
27
30
|
return data.user.roles;
|
|
@@ -46,7 +49,7 @@ export function InviteAcceptScreen({ title, token: tokenProp, loggedInHref = "/"
|
|
|
46
49
|
// challenge / setup requirement, but no session cookie was minted: send the
|
|
47
50
|
// user to the login screen (now a member) instead of a tenant URL.
|
|
48
51
|
const finishAccept = (data) => {
|
|
49
|
-
if (data
|
|
52
|
+
if (isMfaPending(data)) {
|
|
50
53
|
window.location.assign(loginHref);
|
|
51
54
|
return;
|
|
52
55
|
}
|
|
@@ -9,6 +9,18 @@ import { signMfaSetupToken } from "../mfa-setup-token.js";
|
|
|
9
9
|
import { buildOtpauthUri } from "../otpauth-uri.js";
|
|
10
10
|
import { generateRecoveryCodes, hashRecoveryCodes } from "../recovery-codes.js";
|
|
11
11
|
import { generateTotpSecret } from "../totp.js";
|
|
12
|
+
const FALLBACK_ACCOUNT_LABEL = "account";
|
|
13
|
+
// The authenticator app shows this label, so an internal user id must never leak into it.
|
|
14
|
+
function readableAccountLabel(me) {
|
|
15
|
+
if (typeof me !== "object" || me === null)
|
|
16
|
+
return FALLBACK_ACCOUNT_LABEL;
|
|
17
|
+
for (const field of ["email", "displayName"]) {
|
|
18
|
+
const value = Reflect.get(me, field);
|
|
19
|
+
if (typeof value === "string" && value.length > 0)
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
return FALLBACK_ACCOUNT_LABEL;
|
|
23
|
+
}
|
|
12
24
|
// Stateless setup: no `userMfa` row is created here. The generated secret +
|
|
13
25
|
// recovery-code hashes are signed into a short-lived `setupToken` (see
|
|
14
26
|
// mfa-setup-token.ts) that `enable-confirm` verifies. An abandoned setup
|
|
@@ -41,8 +53,7 @@ export function createEnableStartHandler(opts) {
|
|
|
41
53
|
if (!accountLabel) {
|
|
42
54
|
// The declarative secretMint screen has no client component left to
|
|
43
55
|
// read the session email off — derive the otpauth label server-side.
|
|
44
|
-
|
|
45
|
-
accountLabel = me?.email ?? event.user.id;
|
|
56
|
+
accountLabel = readableAccountLabel(await ctx.queryAs(event.user, UserQueries.me, {}));
|
|
46
57
|
}
|
|
47
58
|
const secret = generateTotpSecret();
|
|
48
59
|
const recoveryCodes = generateRecoveryCodes();
|
|
@@ -7,15 +7,15 @@ function hmacSign(input, secret) {
|
|
|
7
7
|
// minted for one MFA step from verifying as another (e.g. a preauth-setup token
|
|
8
8
|
// replayed as a challenge token), since both share the same secret and payload shape.
|
|
9
9
|
export function createHmacTokenCodec(domain, isPayload) {
|
|
10
|
-
function
|
|
10
|
+
function splitEncodedBody(value) {
|
|
11
11
|
if (typeof value !== "object" || value === null)
|
|
12
|
-
return
|
|
12
|
+
return null;
|
|
13
13
|
const v = value;
|
|
14
14
|
const expiresAtMs = v["expiresAtMs"];
|
|
15
15
|
if (typeof expiresAtMs !== "number")
|
|
16
|
-
return
|
|
16
|
+
return null;
|
|
17
17
|
const { expiresAtMs: _drop, ...rest } = v;
|
|
18
|
-
return isPayload(rest);
|
|
18
|
+
return isPayload(rest) ? { payload: rest, expiresAtMs } : null;
|
|
19
19
|
}
|
|
20
20
|
return {
|
|
21
21
|
sign(payload, ttlMinutes, secret, now = Temporal.Now.instant()) {
|
|
@@ -46,14 +46,13 @@ export function createHmacTokenCodec(domain, isPayload) {
|
|
|
46
46
|
catch {
|
|
47
47
|
return { ok: false, reason: "malformed" };
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
const body = splitEncodedBody(parsed);
|
|
50
|
+
if (!body)
|
|
50
51
|
return { ok: false, reason: "malformed" };
|
|
51
|
-
if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(
|
|
52
|
-
0) {
|
|
52
|
+
if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(body.expiresAtMs)) > 0) {
|
|
53
53
|
return { ok: false, reason: "expired" };
|
|
54
54
|
}
|
|
55
|
-
|
|
56
|
-
return { ok: true, payload: payload, expiresAtMs };
|
|
55
|
+
return { ok: true, payload: body.payload, expiresAtMs: body.expiresAtMs };
|
|
57
56
|
},
|
|
58
57
|
};
|
|
59
58
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
// @runtime client
|
|
3
|
+
// Consumers that type-check this source never include the ambient declaration on their own.
|
|
4
|
+
/// <reference path="./qrcode-browser.d.ts" />
|
|
3
5
|
// Pre-auth twin of the declarative auth-mfa-enable secretMint screen: a user
|
|
4
6
|
// blocked at login by MFA enforcement and not yet enrolled lands here
|
|
5
7
|
// straight out of the login flow — there is no session yet. Identity comes
|
|
@@ -25,13 +25,8 @@ function capTierKey(capId, tier) {
|
|
|
25
25
|
return JSON.stringify([capId, tier]);
|
|
26
26
|
}
|
|
27
27
|
async function resolveLimitsByCapAndTier(caps, tiers, context) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
for (const tier of tiers) {
|
|
31
|
-
limitByCapAndTier.set(capTierKey(cap.id, tier), await cap.limit(tier, context));
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return limitByCapAndTier;
|
|
28
|
+
const entries = await Promise.all(caps.flatMap((cap) => [...tiers].map(async (tier) => [capTierKey(cap.id, tier), await cap.limit(tier, context)])));
|
|
29
|
+
return new Map(entries);
|
|
35
30
|
}
|
|
36
31
|
function isSortableField(field) {
|
|
37
32
|
return SORTABLE_FIELDS.includes(field);
|
|
@@ -217,7 +217,7 @@ export function validateBounds(value, keyDef) {
|
|
|
217
217
|
path: "value",
|
|
218
218
|
code: "out_of_bounds",
|
|
219
219
|
i18nKey: "errors.validation.out_of_bounds",
|
|
220
|
-
params: { value, min, max: max ?? null },
|
|
220
|
+
params: { ...echoableValue(keyDef, value), min, max: max ?? null },
|
|
221
221
|
},
|
|
222
222
|
],
|
|
223
223
|
});
|
|
@@ -229,7 +229,7 @@ export function validateBounds(value, keyDef) {
|
|
|
229
229
|
path: "value",
|
|
230
230
|
code: "out_of_bounds",
|
|
231
231
|
i18nKey: "errors.validation.out_of_bounds",
|
|
232
|
-
params: { value, min: min ?? null, max },
|
|
232
|
+
params: { ...echoableValue(keyDef, value), min: min ?? null, max },
|
|
233
233
|
},
|
|
234
234
|
],
|
|
235
235
|
});
|
|
@@ -79,8 +79,9 @@ function registerCustomFields(r, variant) {
|
|
|
79
79
|
// Event-types — qualified als "custom-fields:event:<short-name>".
|
|
80
80
|
// Returned EventDefs liefern .name als compile-time literal-typed string,
|
|
81
81
|
// den Handler + MSP-keys konsumieren statt Template-Literal-Konstruktion.
|
|
82
|
-
// `value: z.unknown()` has no field-level schema to catalog under piiFields
|
|
83
|
-
//
|
|
82
|
+
// `value: z.unknown()` has no field-level schema to catalog under piiFields
|
|
83
|
+
// (event PII covers string fields only), so "no PII" is a convention here,
|
|
84
|
+
// not an enforced guarantee.
|
|
84
85
|
const setEvent = r.defineEvent(CUSTOM_FIELD_SET_EVENT, customFieldSetSchema, {
|
|
85
86
|
piiFields: "none",
|
|
86
87
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import { type SerializedFieldShape } from "./parse-serialized-field.js";
|
|
3
4
|
export type FieldAccessCheckResult = {
|
|
4
5
|
ok: true;
|
|
@@ -13,6 +14,6 @@ export type LoadedFieldDefinition = {
|
|
|
13
14
|
found: true;
|
|
14
15
|
field: SerializedFieldShape | null;
|
|
15
16
|
};
|
|
16
|
-
export declare function loadFieldDefinition(runner: DbRunner, tenantId:
|
|
17
|
+
export declare function loadFieldDefinition(runner: DbRunner, tenantId: TenantId, entityName: string, fieldKey: string): Promise<LoadedFieldDefinition>;
|
|
17
18
|
export declare function fieldWriteAccessDeniedRoles(field: SerializedFieldShape | null, userRoles: ReadonlyArray<string>): ReadonlyArray<string> | null;
|
|
18
|
-
export declare function checkFieldAccessForWrite(runner: DbRunner, tenantId:
|
|
19
|
+
export declare function checkFieldAccessForWrite(runner: DbRunner, tenantId: TenantId, entityName: string, fieldKey: string, userRoles: ReadonlyArray<string>): Promise<FieldAccessCheckResult>;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// value-validation off one DB read (no double fetch).
|
|
4
4
|
import { selectSerializedFieldDefinition } from "../db/queries/field-access.js";
|
|
5
5
|
import { parseSerializedField } from "./parse-serialized-field.js";
|
|
6
|
+
// `runner` is deliberately unscoped: tenant isolation rests solely on `tenantId`.
|
|
6
7
|
export async function loadFieldDefinition(runner, tenantId, entityName, fieldKey) {
|
|
7
8
|
const serialized = await selectSerializedFieldDefinition(runner, tenantId, entityName, fieldKey);
|
|
8
9
|
if (serialized === null)
|
|
@@ -168,6 +168,7 @@ function isResubscribeLimitReached(error) {
|
|
|
168
168
|
const details = error.details;
|
|
169
169
|
return (typeof details === "object" &&
|
|
170
170
|
details !== null &&
|
|
171
|
+
"reason" in details &&
|
|
171
172
|
details.reason === DeliveryErrors.resubscribeLimitReached);
|
|
172
173
|
}
|
|
173
174
|
async function dispatchResubscribeWrite(verified, deps) {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
// not one query per key.
|
|
48
48
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
49
49
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
50
|
-
import { createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
|
|
50
|
+
import { createSystemUser, parseTenantId, } from "@cosmicdrift/kumiko-framework/engine";
|
|
51
51
|
import { assertSafeStorageKey, fileRefEntity, fileRefsTable, storageKeyStemPrefix, tenantStoragePrefixes, } from "@cosmicdrift/kumiko-framework/files";
|
|
52
52
|
const crud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
|
|
53
53
|
// Existence-check batch size for fileRefStorageDestroyHook's survivor lookup
|
|
@@ -83,6 +83,13 @@ export const fileRefTenantDestroyHook = async (ctx) => {
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
};
|
|
86
|
+
// The bytes live under the tenant that uploaded them (`<tenantId>/…` or
|
|
87
|
+
// `exports/<tenantId>/…`), so with per-tenant storage config that tenant's
|
|
88
|
+
// provider — not the destroyed destination tenant's — holds them.
|
|
89
|
+
function storageKeyOwnerTenant(storageKey, fallback) {
|
|
90
|
+
const [first, second] = storageKey.split("/");
|
|
91
|
+
return parseTenantId(first) ?? (first === "exports" ? parseTenantId(second) : null) ?? fallback;
|
|
92
|
+
}
|
|
86
93
|
async function deleteHandedOverBinary(ctx, storageKey) {
|
|
87
94
|
if (!ctx.fileProviderResolver) {
|
|
88
95
|
// skip: same graceful-degradation stance as fileRefStorageDestroyHook —
|
|
@@ -91,12 +98,13 @@ async function deleteHandedOverBinary(ctx, storageKey) {
|
|
|
91
98
|
// skip: no provider wired, so there is no store to delete from — logged above.
|
|
92
99
|
return;
|
|
93
100
|
}
|
|
101
|
+
const ownerTenantId = storageKeyOwnerTenant(storageKey, ctx.tenantId);
|
|
94
102
|
let provider;
|
|
95
103
|
try {
|
|
96
|
-
provider = await ctx.fileProviderResolver(
|
|
104
|
+
provider = await ctx.fileProviderResolver(ownerTenantId);
|
|
97
105
|
}
|
|
98
106
|
catch (err) {
|
|
99
|
-
ctx.log?.(`[files-tenant-data] no file provider resolvable for tenant ${
|
|
107
|
+
ctx.log?.(`[files-tenant-data] no file provider resolvable for tenant ${ownerTenantId}: ${err instanceof Error ? err.message : String(err)} — handed-over binary for ${storageKey} NOT deleted`);
|
|
100
108
|
// skip: provider resolution failed, so there is nothing this call can delete — logged above.
|
|
101
109
|
return;
|
|
102
110
|
}
|
|
@@ -25,7 +25,14 @@ export function FolderSection({ entityName, entityId, }) {
|
|
|
25
25
|
const dispatcher = useDispatcher();
|
|
26
26
|
const enabled = entityId !== null;
|
|
27
27
|
const catalog = useQuery(FoldersQueries.folderList, {}, { enabled });
|
|
28
|
-
const assignments = useQuery(FoldersQueries.assignmentList, {
|
|
28
|
+
const assignments = useQuery(FoldersQueries.assignmentList, {
|
|
29
|
+
// entityType server-side, not just entityId: it collapses the parent-ref
|
|
30
|
+
// read gate to this one host entity instead of every registered one.
|
|
31
|
+
filters: [
|
|
32
|
+
{ field: "entityType", op: "eq", value: entityName },
|
|
33
|
+
{ field: "entityId", op: "eq", value: entityId },
|
|
34
|
+
],
|
|
35
|
+
}, { enabled });
|
|
29
36
|
const [newName, setNewName] = useState("");
|
|
30
37
|
const [busy, setBusy] = useState(false);
|
|
31
38
|
const [errorKey, setErrorKey] = useState(null);
|
|
@@ -67,7 +67,7 @@ export const inboundMailFoundationFeature = defineFeature(INBOUND_MAIL_FOUNDATIO
|
|
|
67
67
|
// piiFields: "none" — address/subject/snippet are already ciphertext under the tenant subject key before append (see events.ts).
|
|
68
68
|
// "displayName" stays plaintext (often the mailbox owner's real name for
|
|
69
69
|
// personal accounts); encrypting it would break the entity-projection read
|
|
70
|
-
// path that carries it as `personal: false
|
|
70
|
+
// path that carries it as `personal: false`.
|
|
71
71
|
r.defineEvent(MAIL_ACCOUNT_CONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema, {
|
|
72
72
|
piiFields: "none",
|
|
73
73
|
});
|
package/dist/jobs/constants.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare const JobErrors: {
|
|
|
14
14
|
readonly notFound: "not_found";
|
|
15
15
|
readonly onlyFailedCanRetry: "only_failed_jobs_can_be_retried";
|
|
16
16
|
readonly notManual: "job_not_manual";
|
|
17
|
+
readonly invalidPayload: "job_invalid_payload";
|
|
17
18
|
readonly payloadErased: "job_payload_erased";
|
|
18
19
|
};
|
|
19
20
|
/** SystemAdmin job-runs list. Nav: `jobs:screen:job-runs`. */
|
package/dist/jobs/constants.js
CHANGED
|
@@ -19,6 +19,7 @@ export const JobErrors = {
|
|
|
19
19
|
notFound: "not_found",
|
|
20
20
|
onlyFailedCanRetry: "only_failed_jobs_can_be_retried",
|
|
21
21
|
notManual: "job_not_manual",
|
|
22
|
+
invalidPayload: "job_invalid_payload",
|
|
22
23
|
payloadErased: "job_payload_erased",
|
|
23
24
|
};
|
|
24
25
|
/** SystemAdmin job-runs list. Nav: `jobs:screen:job-runs`. */
|
|
@@ -3,6 +3,11 @@ import { NotFoundError, UnprocessableError, validationErrorFromZod, writeFailure
|
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { JobErrors } from "../constants.js";
|
|
5
5
|
import { isManualTrigger } from "../is-manual-trigger.js";
|
|
6
|
+
function invalidPayloadError() {
|
|
7
|
+
return new UnprocessableError(JobErrors.invalidPayload, {
|
|
8
|
+
i18nKey: "jobs.errors.invalidPayload",
|
|
9
|
+
});
|
|
10
|
+
}
|
|
6
11
|
export const triggerWrite = defineWriteHandler({
|
|
7
12
|
name: "trigger",
|
|
8
13
|
description: "Starts one manually-triggerable job with the given payload after validating it against the job's schema; use it to run a maintenance or import job on demand.",
|
|
@@ -32,12 +37,12 @@ export const triggerWrite = defineWriteHandler({
|
|
|
32
37
|
try {
|
|
33
38
|
const parsed = JSON.parse(event.payload.payload);
|
|
34
39
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
35
|
-
return writeFailure(
|
|
40
|
+
return writeFailure(invalidPayloadError());
|
|
36
41
|
}
|
|
37
42
|
rawPayload = parsed;
|
|
38
43
|
}
|
|
39
44
|
catch {
|
|
40
|
-
return writeFailure(
|
|
45
|
+
return writeFailure(invalidPayloadError());
|
|
41
46
|
}
|
|
42
47
|
}
|
|
43
48
|
else if (event.payload.payload !== undefined) {
|
package/dist/jobs/i18n.js
CHANGED
|
@@ -38,6 +38,9 @@ export const JOBS_I18N = {
|
|
|
38
38
|
"jobs.errors.notManual": {
|
|
39
39
|
en: "This job cannot be triggered manually.",
|
|
40
40
|
},
|
|
41
|
+
"jobs.errors.invalidPayload": {
|
|
42
|
+
en: "The payload must be a valid JSON object.",
|
|
43
|
+
},
|
|
41
44
|
"jobs.errors.notFound": { en: "Not found." },
|
|
42
45
|
"jobs.errors.onlyFailedCanRetry": {
|
|
43
46
|
en: "Only failed runs can be retried.",
|
package/dist/metrics/compute.js
CHANGED
|
@@ -35,20 +35,31 @@ async function fetchAggregates(db, metric, wheres, bucketDimension) {
|
|
|
35
35
|
const aggregate = (groupBy, where, extra = {}) => db.aggregate(metric.source, { measure: metric.measure, groupBy, ...extra }, where);
|
|
36
36
|
const { groupBy, stackBy } = metric;
|
|
37
37
|
const { current } = wheres;
|
|
38
|
+
const groupsPromise = groupBy === undefined
|
|
39
|
+
? Promise.resolve([])
|
|
40
|
+
: aggregate([{ field: groupBy }], current, { orderByValue: "desc", limit: MAX_GROUP_ROWS });
|
|
41
|
+
// Stacks/series are only rendered for the top groups. A NULL group key can't be
|
|
42
|
+
// matched by IN, so a null among the top groups falls back to the unrestricted scan.
|
|
43
|
+
const groupedWhere = async () => {
|
|
44
|
+
if (groupBy === undefined)
|
|
45
|
+
return current;
|
|
46
|
+
const keys = (await groupsPromise).map((row) => row.keys[0]);
|
|
47
|
+
return keys.length === 0 || keys.includes(null) || keys.includes(undefined)
|
|
48
|
+
? current
|
|
49
|
+
: { ...current, [groupBy]: keys };
|
|
50
|
+
};
|
|
38
51
|
const [total, previous, points, groups, stacks, series] = await Promise.all([
|
|
39
52
|
aggregate([], current),
|
|
40
53
|
wheres.previous === undefined ? Promise.resolve(undefined) : aggregate([], wheres.previous),
|
|
41
54
|
bucketDimension !== undefined && groupBy === undefined
|
|
42
55
|
? aggregate([bucketDimension], current)
|
|
43
56
|
: Promise.resolve([]),
|
|
44
|
-
|
|
45
|
-
? Promise.resolve([])
|
|
46
|
-
: aggregate([{ field: groupBy }], current, { orderByValue: "desc", limit: MAX_GROUP_ROWS }),
|
|
57
|
+
groupsPromise,
|
|
47
58
|
groupBy !== undefined && stackBy !== undefined
|
|
48
|
-
? aggregate([{ field: groupBy }, { field: stackBy }],
|
|
59
|
+
? groupedWhere().then((where) => aggregate([{ field: groupBy }, { field: stackBy }], where))
|
|
49
60
|
: Promise.resolve([]),
|
|
50
61
|
bucketDimension !== undefined && groupBy !== undefined
|
|
51
|
-
? aggregate([{ field: groupBy }, bucketDimension],
|
|
62
|
+
? groupedWhere().then((where) => aggregate([{ field: groupBy }, bucketDimension], where))
|
|
52
63
|
: Promise.resolve([]),
|
|
53
64
|
]);
|
|
54
65
|
return { total, previous, points, groups, stacks, series };
|
|
@@ -71,8 +71,8 @@ export function createNotesHistoryFeature(opts = {}) {
|
|
|
71
71
|
// of passing an empty array.
|
|
72
72
|
if (opts.parents !== undefined && opts.parents.length === 0) {
|
|
73
73
|
throw new Error("createNotesHistoryFeature({ parents }): parents must not be an empty array — " +
|
|
74
|
-
"an empty allowlist rejects every add-note call. Omit `parents` to
|
|
75
|
-
"
|
|
74
|
+
"an empty allowlist rejects every add-note call. Omit `parents` to allow " +
|
|
75
|
+
"any registered entity the caller can read as a note parent.");
|
|
76
76
|
}
|
|
77
77
|
return defineFeature(NOTES_HISTORY_FEATURE_NAME, (r) => registerNotesHistory(r, resolveAccess(opts), opts.ownership, opts.parents));
|
|
78
78
|
}
|
|
@@ -35,7 +35,8 @@ export function createAddNoteHandler(access = DEFAULT_NOTES_HISTORY_ACCESS) {
|
|
|
35
35
|
try {
|
|
36
36
|
// read_users is tenant-agnostic → ctx.db.unsafeRaw, not the tenant-scoped ctx.db.
|
|
37
37
|
// Bun.SQL poisons the whole tx after any error inside it, even one that's
|
|
38
|
-
// caught —
|
|
38
|
+
// caught — so the lookup runs in a savepoint where available, otherwise directly
|
|
39
|
+
// (pool statements are their own units).
|
|
39
40
|
authorName = await runInSavepointIfSupported(ctx.db.unsafeRaw(READ_AUTHOR_DISPLAY_NAME_REASON), async (sp) => {
|
|
40
41
|
const userRow = await fetchOne(sp, userTable, {
|
|
41
42
|
id: event.user.id,
|
|
@@ -26,7 +26,12 @@ export function NotesSection({ entityName, entityId, }) {
|
|
|
26
26
|
const dispatcher = useDispatcher();
|
|
27
27
|
const enabled = entityId !== null;
|
|
28
28
|
const notes = useQuery(NotesHistoryQueries.noteList, {
|
|
29
|
-
|
|
29
|
+
// entityType server-side, not just entityId: it collapses the parent-ref
|
|
30
|
+
// read gate to this one host entity instead of every registered one.
|
|
31
|
+
filters: [
|
|
32
|
+
{ field: "entityType", op: "eq", value: entityName },
|
|
33
|
+
{ field: "entityId", op: "eq", value: entityId },
|
|
34
|
+
],
|
|
30
35
|
sort: "insertedAt",
|
|
31
36
|
sortDirection: "desc",
|
|
32
37
|
// ponytail: fixed page, no "load more" — an entity with a note history
|
|
@@ -42,27 +42,27 @@ export const apiTokenEntity = createEntity({
|
|
|
42
42
|
// store ciphertext while the resolver keeps querying for the plaintext
|
|
43
43
|
// hash — every bearer-token request would 401 once a KMS is active.
|
|
44
44
|
// Fixing that needs a blind-index-backed lookup (like email's
|
|
45
|
-
// find:"exact" companion column), a resolver.ts rewrite
|
|
46
|
-
//
|
|
45
|
+
// find:"exact" companion column), a resolver.ts rewrite, so this stays
|
|
46
|
+
// unencrypted until a blind-index lookup exists.
|
|
47
47
|
tokenHash: createTextField({
|
|
48
48
|
required: true,
|
|
49
49
|
maxLength: 64,
|
|
50
50
|
access: { write: access.privileged },
|
|
51
51
|
personal: false,
|
|
52
|
-
reason: "
|
|
52
|
+
reason: "credential_hash_used_as_exact_lookup_key",
|
|
53
53
|
}),
|
|
54
54
|
// Short displayable prefix ("your tokens" list) — not the secret itself,
|
|
55
55
|
// but still identifies which of the user's tokens this row is. Kept
|
|
56
56
|
// Klasse 1: list.query.ts returns `r.prefix` straight through with no
|
|
57
57
|
// decrypt call (unlike `name`, which goes through decryptStoredPii) — a
|
|
58
58
|
// subject annotation would leak ciphertext to the UI once a KMS is
|
|
59
|
-
// active.
|
|
59
|
+
// active.
|
|
60
60
|
prefix: createTextField({
|
|
61
61
|
required: true,
|
|
62
62
|
maxLength: 16,
|
|
63
63
|
access: { write: access.privileged },
|
|
64
64
|
personal: false,
|
|
65
|
-
reason: "
|
|
65
|
+
reason: "credential_prefix_read_without_decrypt",
|
|
66
66
|
}),
|
|
67
67
|
// JSON-encoded string[] of granted scope names — mirrors the roles-column
|
|
68
68
|
// convention (parseRoles-style text); the resolver JSON.parses it.
|
|
@@ -1,8 +1,5 @@
|
|
|
1
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
2
|
import { AccessDeniedError } from "@cosmicdrift/kumiko-framework/errors";
|
|
2
|
-
type BucketCaller =
|
|
3
|
-
readonly id: string;
|
|
4
|
-
readonly tenantId: string;
|
|
5
|
-
readonly roles: readonly string[];
|
|
6
|
-
};
|
|
3
|
+
type BucketCaller = Pick<SessionUser, "id" | "tenantId" | "roles">;
|
|
7
4
|
export declare function bucketAccessDenied(bucket: string, caller: BucketCaller): AccessDeniedError | undefined;
|
|
8
5
|
export {};
|
package/dist/secrets/feature.js
CHANGED
|
@@ -7,6 +7,7 @@ import { createDeleteHandler } from "./handlers/delete.write.js";
|
|
|
7
7
|
import { createListHandler } from "./handlers/list.query.js";
|
|
8
8
|
import { rotateJob } from "./handlers/rotate.job.js";
|
|
9
9
|
import { createSetHandler } from "./handlers/set.write.js";
|
|
10
|
+
import { SECRETS_I18N } from "./i18n.js";
|
|
10
11
|
import { secretReadSchema } from "./secrets-context.js";
|
|
11
12
|
import { tenantSecretEntity, tenantSecretsTable } from "./table.js";
|
|
12
13
|
const ROTATE_ESCAPE_HATCH_REASON = "re-encrypts every tenant's secrets with the new master key";
|
|
@@ -102,6 +103,7 @@ export function createSecretsFeature(opts = {}) {
|
|
|
102
103
|
// covering both boot-time validation and rendering without a web
|
|
103
104
|
// client-plugin for this feature.
|
|
104
105
|
r.translations({ keys: SETTINGS_HUB_I18N });
|
|
106
|
+
r.translations({ keys: SECRETS_I18N });
|
|
105
107
|
// ES entity: set/delete go through the executor, `tenantSecret.created/
|
|
106
108
|
// .updated/.deleted` events land on the aggregate stream. Reads fire a
|
|
107
109
|
// separate `tenantSecretRead` event per call (see secrets-context.get
|
|
@@ -3,6 +3,7 @@ import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
|
|
|
3
3
|
import * as z from "zod";
|
|
4
4
|
import { DEFAULT_SECRETS_ACCESS } from "../constants.js";
|
|
5
5
|
import { requireSecretsContext } from "../feature.js";
|
|
6
|
+
import { checkSecretKeyWrite } from "../write-gate.js";
|
|
6
7
|
export function createDeleteHandler(access = DEFAULT_SECRETS_ACCESS) {
|
|
7
8
|
return defineWriteHandler({
|
|
8
9
|
name: "delete",
|
|
@@ -14,6 +15,9 @@ export function createDeleteHandler(access = DEFAULT_SECRETS_ACCESS) {
|
|
|
14
15
|
access,
|
|
15
16
|
handler: async (event, ctx) => {
|
|
16
17
|
const secrets = requireSecretsContext(ctx, "secrets:write:delete");
|
|
18
|
+
const gate = checkSecretKeyWrite(ctx.registry, event.user.roles, event.payload.key);
|
|
19
|
+
if (!gate.ok)
|
|
20
|
+
return gate.failure;
|
|
17
21
|
const removed = await secrets.delete(event.user.tenantId, event.payload.key, {
|
|
18
22
|
deletedBy: event.user.id,
|
|
19
23
|
});
|