@cosmicdrift/kumiko-bundled-features 0.105.2 → 0.108.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 +8 -6
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
- package/src/auth-email-password/handlers/change-password.write.ts +11 -11
- package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
- package/src/auth-email-password/web/auth-gate.tsx +3 -4
- package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
- package/src/auth-email-password/web/session.tsx +5 -1
- package/src/auth-email-password/web/user-menu.tsx +1 -1
- package/src/config/__tests__/deserialize-value.test.ts +3 -6
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
- package/src/custom-fields/constants.ts +1 -0
- package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
- package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
- package/src/custom-fields/wire-for-entity.ts +104 -77
- package/src/data-retention/__tests__/parse-override.test.ts +11 -6
- package/src/data-retention/resolve-for-tenant.ts +16 -4
- package/src/data-retention/run-retention-cleanup.ts +72 -10
- package/src/delivery/attempt-log.ts +22 -10
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
- package/src/folders/__tests__/folders.integration.test.ts +26 -0
- package/src/folders/feature.ts +4 -2
- package/src/folders/handlers/delete-folder.write.ts +40 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +24 -0
- package/src/folders/web/folder-manager.tsx +18 -13
- package/src/folders/web/i18n.ts +0 -2
- package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
- package/src/folders-user-data/hooks.ts +31 -8
- package/src/folders-user-data/index.ts +4 -4
- package/src/jobs/feature.ts +23 -21
- package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
- package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
- package/src/ledger/__tests__/reports.test.ts +20 -0
- package/src/ledger/entity.ts +1 -1
- package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
- package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
- package/src/ledger/recurring.ts +18 -9
- package/src/ledger/schemas.ts +4 -1
- package/src/ledger/web/index.ts +3 -2
- package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +204 -0
- package/src/personal-access-tokens/constants.ts +33 -0
- package/src/personal-access-tokens/feature.ts +78 -0
- package/src/personal-access-tokens/handlers/available-scopes.query.ts +15 -0
- package/src/personal-access-tokens/handlers/create.write.ts +41 -0
- package/src/personal-access-tokens/handlers/list.query.ts +47 -0
- package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
- package/src/personal-access-tokens/hash.ts +17 -0
- package/src/personal-access-tokens/index.ts +45 -0
- package/src/personal-access-tokens/resolver.ts +58 -0
- package/src/personal-access-tokens/roles.ts +29 -0
- package/src/personal-access-tokens/schema/api-token.ts +56 -0
- package/src/personal-access-tokens/scopes.ts +22 -0
- package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
- package/src/personal-access-tokens/web/i18n.ts +45 -0
- package/src/personal-access-tokens/web/index.ts +7 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +150 -0
- package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
- package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
- package/src/sessions/schema/user-session.ts +8 -2
- package/src/sessions/session-callbacks.ts +9 -3
- package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
- package/src/tags/__tests__/tags.integration.test.ts +15 -0
- package/src/tags/handlers/assign-tag.write.ts +13 -3
- package/src/tags/schemas.ts +10 -4
- package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
- package/src/template-resolver/testing.ts +4 -3
- package/src/tier-engine/feature.ts +22 -10
- package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
- package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
- package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
- package/src/user/index.ts +4 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
- package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
- package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
- package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
- package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
- package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
- package/src/user-data-rights/run-forget-cleanup.ts +8 -1
- package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
- package/src/user-data-rights/web/i18n.ts +0 -10
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
- package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
- package/src/user-profile/handlers/change-email.write.ts +11 -8
- package/src/user-profile/web/profile-screen.tsx +10 -8
- package/src/auth-email-password/stream-tenant.ts +0 -31
- package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { Temporal } from "temporal-polyfill";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { PatErrors } from "../constants";
|
|
7
|
+
import { apiTokenTable } from "../schema/api-token";
|
|
8
|
+
|
|
9
|
+
// Revoke one of the caller's own tokens. Ownership is enforced in the WHERE
|
|
10
|
+
// (userId = caller), so a caller can't revoke another user's token and a miss
|
|
11
|
+
// is a uniform ownership error with no existence oracle. isNull(revokedAt)
|
|
12
|
+
// keeps a double-revoke from overwriting the original timestamp.
|
|
13
|
+
export const revokePatWrite = defineWriteHandler({
|
|
14
|
+
name: "revoke",
|
|
15
|
+
schema: z.object({ id: z.uuid() }),
|
|
16
|
+
access: { openToAll: true },
|
|
17
|
+
handler: async (event, ctx) => {
|
|
18
|
+
const updated = await updateMany(
|
|
19
|
+
ctx.db,
|
|
20
|
+
apiTokenTable,
|
|
21
|
+
{ revokedAt: Temporal.Now.instant() },
|
|
22
|
+
{ id: event.payload.id, userId: event.user.id, revokedAt: null },
|
|
23
|
+
);
|
|
24
|
+
if (updated.length > 0) return { isSuccess: true, data: { id: event.payload.id } };
|
|
25
|
+
return writeFailure(
|
|
26
|
+
new UnprocessableError(PatErrors.ownershipDenied, {
|
|
27
|
+
i18nKey: "errors.ownershipDenied",
|
|
28
|
+
details: {
|
|
29
|
+
scope: "entity",
|
|
30
|
+
entityName: "api-token",
|
|
31
|
+
action: "revoke",
|
|
32
|
+
userId: event.user.id,
|
|
33
|
+
},
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { generateToken, PAT_TOKEN_PREFIX } from "@cosmicdrift/kumiko-framework/api";
|
|
3
|
+
import { PAT_PREFIX_DISPLAY_LENGTH } from "./constants";
|
|
4
|
+
|
|
5
|
+
// A PAT is high-entropy (32 random bytes from generateToken). A single SHA-256
|
|
6
|
+
// is the right hash here: fast on the per-request auth path, and there's no
|
|
7
|
+
// brute-force surface that would justify argon2 (that's for low-entropy
|
|
8
|
+
// passwords). Only the hash is persisted — the plaintext exists once, in the
|
|
9
|
+
// create response.
|
|
10
|
+
export function hashPatToken(rawToken: string): string {
|
|
11
|
+
return createHash("sha256").update(rawToken).digest("hex");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function mintPatToken(): { raw: string; hash: string; prefix: string } {
|
|
15
|
+
const raw = `${PAT_TOKEN_PREFIX}${generateToken()}`;
|
|
16
|
+
return { raw, hash: hashPatToken(raw), prefix: raw.slice(0, PAT_PREFIX_DISPLAY_LENGTH) };
|
|
17
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { PAT_DEFAULT_RATE_LIMIT, type PatRateLimit } from "./constants";
|
|
3
|
+
import type { PatScopeConfig } from "./scopes";
|
|
4
|
+
|
|
5
|
+
export type { PatRateLimit } from "./constants";
|
|
6
|
+
export { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, PatHandlers, PatQueries } from "./constants";
|
|
7
|
+
export type { PersonalAccessTokensOptions } from "./feature";
|
|
8
|
+
export { createPersonalAccessTokensFeature } from "./feature";
|
|
9
|
+
export { hashPatToken, mintPatToken } from "./hash";
|
|
10
|
+
export { createPatResolver } from "./resolver";
|
|
11
|
+
export { apiTokenEntity, apiTokenTable } from "./schema/api-token";
|
|
12
|
+
export type { PatScopeConfig, PatScopeDef } from "./scopes";
|
|
13
|
+
export { expandScopes } from "./scopes";
|
|
14
|
+
|
|
15
|
+
// Reads the scope catalog off a mounted PAT feature's exports (set from the
|
|
16
|
+
// setup-callback return). run-prod-app uses this to build the resolver from the
|
|
17
|
+
// same config the handlers were given. Returns {} for a non-PAT / malformed
|
|
18
|
+
// definition — the caller has already matched on feature.name.
|
|
19
|
+
export function patScopesFromFeature(feature: FeatureDefinition): PatScopeConfig {
|
|
20
|
+
const exports = feature.exports;
|
|
21
|
+
if (exports && typeof exports === "object" && "scopes" in exports) {
|
|
22
|
+
const { scopes } = exports as { scopes: unknown };
|
|
23
|
+
if (scopes && typeof scopes === "object") return scopes as PatScopeConfig;
|
|
24
|
+
}
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Reads the per-token rate-limit config off a mounted PAT feature's exports.
|
|
29
|
+
// Falls back to the default when absent. run-prod-app uses this to build the
|
|
30
|
+
// limiter from the same declaration the feature was given.
|
|
31
|
+
export function patRateLimitFromFeature(feature: FeatureDefinition): PatRateLimit {
|
|
32
|
+
const exports = feature.exports;
|
|
33
|
+
if (exports && typeof exports === "object" && "rateLimit" in exports) {
|
|
34
|
+
const { rateLimit } = exports as { rateLimit: unknown };
|
|
35
|
+
if (
|
|
36
|
+
rateLimit &&
|
|
37
|
+
typeof rateLimit === "object" &&
|
|
38
|
+
"maxRequests" in rateLimit &&
|
|
39
|
+
"windowMs" in rateLimit
|
|
40
|
+
) {
|
|
41
|
+
return rateLimit as PatRateLimit;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return PAT_DEFAULT_RATE_LIMIT;
|
|
45
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { PatResolver } from "@cosmicdrift/kumiko-framework/api";
|
|
2
|
+
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
+
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import { Temporal } from "temporal-polyfill";
|
|
6
|
+
import { hashPatToken } from "./hash";
|
|
7
|
+
import { resolvePatRoles } from "./roles";
|
|
8
|
+
import { apiTokenTable } from "./schema/api-token";
|
|
9
|
+
import { expandScopes, type PatScopeConfig } from "./scopes";
|
|
10
|
+
|
|
11
|
+
// Hot-path resolver — raw-DB like session-callbacks (a dispatcher roundtrip on
|
|
12
|
+
// every request buys nothing here). Hash the bearer token, point-read the row,
|
|
13
|
+
// reject revoked/expired, resolve LIVE roles, expand granted scopes into
|
|
14
|
+
// allowedQns. Any failure returns null so the middleware answers a uniform 401
|
|
15
|
+
// with no oracle about which check failed.
|
|
16
|
+
export function createPatResolver(opts: {
|
|
17
|
+
readonly db: DbConnection;
|
|
18
|
+
readonly scopes: PatScopeConfig;
|
|
19
|
+
}): PatResolver {
|
|
20
|
+
const { db, scopes } = opts;
|
|
21
|
+
return async (rawToken: string): Promise<SessionUser | null> => {
|
|
22
|
+
const row = await fetchOne<{
|
|
23
|
+
id: string;
|
|
24
|
+
userId: string;
|
|
25
|
+
tenantId: string;
|
|
26
|
+
scopes: string;
|
|
27
|
+
revokedAt: unknown;
|
|
28
|
+
expiresAt: { epochMilliseconds: number } | null;
|
|
29
|
+
}>(db, apiTokenTable, { tokenHash: hashPatToken(rawToken) });
|
|
30
|
+
if (!row) return null;
|
|
31
|
+
if (row.revokedAt !== null) return null;
|
|
32
|
+
if (
|
|
33
|
+
row.expiresAt &&
|
|
34
|
+
row.expiresAt.epochMilliseconds <= Temporal.Now.instant().epochMilliseconds
|
|
35
|
+
) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const roles = await resolvePatRoles(db, row.userId, row.tenantId);
|
|
39
|
+
if (!roles) return null;
|
|
40
|
+
const granted = parseScopeNames(row.scopes);
|
|
41
|
+
return {
|
|
42
|
+
id: row.userId,
|
|
43
|
+
// @cast-boundary db-row → branded id: stored from a valid SessionUser.tenantId at create
|
|
44
|
+
tenantId: row.tenantId as TenantId,
|
|
45
|
+
roles,
|
|
46
|
+
pat: { tokenId: row.id, scopes: granted, allowedQns: expandScopes(scopes, granted) },
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseScopeNames(raw: string): string[] {
|
|
52
|
+
try {
|
|
53
|
+
const parsed: unknown = JSON.parse(raw);
|
|
54
|
+
return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { stripForbiddenMembershipRoles } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
5
|
+
import { tenantMembershipsTable } from "../tenant";
|
|
6
|
+
import { userTable } from "../user";
|
|
7
|
+
|
|
8
|
+
// Live role resolution for a (userId, tenantId), mirroring login.write.ts:
|
|
9
|
+
// global roles (users.roles) ∪ tenant-membership roles (forbidden roles
|
|
10
|
+
// stripped). Resolved fresh on every PAT request — a snapshot baked at mint
|
|
11
|
+
// time would keep a since-revoked admin role for the token's whole (months-long)
|
|
12
|
+
// life. Returns null when the user has no membership in that tenant: removed
|
|
13
|
+
// from the tenant → the PAT stops authenticating there.
|
|
14
|
+
export async function resolvePatRoles(
|
|
15
|
+
db: DbConnection,
|
|
16
|
+
userId: string,
|
|
17
|
+
tenantId: string,
|
|
18
|
+
): Promise<readonly string[] | null> {
|
|
19
|
+
const memberships = await selectMany<{ roles: string }>(db, tenantMembershipsTable, {
|
|
20
|
+
userId,
|
|
21
|
+
tenantId,
|
|
22
|
+
});
|
|
23
|
+
const membership = memberships[0];
|
|
24
|
+
if (!membership) return null;
|
|
25
|
+
const userRow = await fetchOne<{ roles: string | null }>(db, userTable, { id: userId });
|
|
26
|
+
const globalRoles = parseRoles(userRow?.roles ?? null);
|
|
27
|
+
const membershipRoles = stripForbiddenMembershipRoles(parseRoles(membership.roles));
|
|
28
|
+
return [...new Set([...globalRoles, ...membershipRoles])];
|
|
29
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
createEntity,
|
|
5
|
+
createTextField,
|
|
6
|
+
createTimestampField,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
|
|
9
|
+
// One row per Personal Access Token. Like read_user_sessions this is a
|
|
10
|
+
// direct-write store (r.unmanagedTable): the create/revoke handlers write it and
|
|
11
|
+
// the resolver point-reads it on the hot auth path. `tokenHash` is the SHA-256
|
|
12
|
+
// of the plaintext (never the plaintext); the unique index on it makes the
|
|
13
|
+
// resolver a single point-read. All columns are privileged-write so no user
|
|
14
|
+
// request can forge ownership/scope/hash by poking a field directly — the
|
|
15
|
+
// handlers mutate them via ctx.db inside the pipeline.
|
|
16
|
+
export const apiTokenEntity = createEntity({
|
|
17
|
+
table: "read_api_tokens",
|
|
18
|
+
// No softDelete: revocation is its own lifecycle (revokedAt timestamp), and
|
|
19
|
+
// we keep revoked rows for the "your tokens" audit list.
|
|
20
|
+
softDelete: false,
|
|
21
|
+
fields: {
|
|
22
|
+
userId: createTextField({
|
|
23
|
+
required: true,
|
|
24
|
+
maxLength: 36,
|
|
25
|
+
access: { write: access.privileged },
|
|
26
|
+
}),
|
|
27
|
+
tenantId: createTextField({
|
|
28
|
+
required: true,
|
|
29
|
+
maxLength: 36,
|
|
30
|
+
access: { write: access.privileged },
|
|
31
|
+
}),
|
|
32
|
+
name: createTextField({ required: true, maxLength: 120, access: { write: access.privileged } }),
|
|
33
|
+
tokenHash: createTextField({
|
|
34
|
+
required: true,
|
|
35
|
+
maxLength: 64,
|
|
36
|
+
access: { write: access.privileged },
|
|
37
|
+
}),
|
|
38
|
+
prefix: createTextField({
|
|
39
|
+
required: true,
|
|
40
|
+
maxLength: 16,
|
|
41
|
+
access: { write: access.privileged },
|
|
42
|
+
}),
|
|
43
|
+
// JSON-encoded string[] of granted scope names — mirrors the roles-column
|
|
44
|
+
// convention (parseRoles-style text); the resolver JSON.parses it.
|
|
45
|
+
scopes: createTextField({ required: true, access: { write: access.privileged } }),
|
|
46
|
+
createdAt: createTimestampField({ required: true, access: { write: access.privileged } }),
|
|
47
|
+
expiresAt: createTimestampField({ access: { write: access.privileged } }),
|
|
48
|
+
revokedAt: createTimestampField({ access: { write: access.privileged } }),
|
|
49
|
+
},
|
|
50
|
+
indexes: [{ unique: true, columns: ["tokenHash"], name: "read_api_tokens_hash_unique" }],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// buildEntityTableMeta (not buildEntityTable): this is a direct-write store, so
|
|
54
|
+
// the table must be a WritableTable (post ES-write-brand #742) — same as
|
|
55
|
+
// sessions' userSessionTable. buildEntityTable is branded executor-only.
|
|
56
|
+
export const apiTokenTable = buildEntityTableMeta("api-token", apiTokenEntity);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// App-declared scopes. Each scope is a named bundle of QN globs the app author
|
|
2
|
+
// wires when mounting the feature — a scope may span features (e.g. a "miete"
|
|
3
|
+
// scope granting ledger + folders QNs). The token stores granted scope NAMES;
|
|
4
|
+
// the resolver expands them to globs at request time.
|
|
5
|
+
export type PatScopeDef = {
|
|
6
|
+
readonly label: string;
|
|
7
|
+
readonly qns: readonly string[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type PatScopeConfig = Readonly<Record<string, PatScopeDef>>;
|
|
11
|
+
|
|
12
|
+
// Expand granted scope names into the union of their QN globs. Unknown names
|
|
13
|
+
// (scope dropped from config after a token was minted) contribute nothing —
|
|
14
|
+
// fail-closed: the token silently loses that capability rather than erroring.
|
|
15
|
+
export function expandScopes(config: PatScopeConfig, granted: readonly string[]): string[] {
|
|
16
|
+
const out = new Set<string>();
|
|
17
|
+
for (const name of granted) {
|
|
18
|
+
const def = config[name];
|
|
19
|
+
if (def) for (const qn of def.qns) out.add(qn);
|
|
20
|
+
}
|
|
21
|
+
return [...out];
|
|
22
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Client-feature factory for personal-access-tokens. Ships the PatTokensScreen
|
|
3
|
+
// (mapped to the "api-tokens" screen id) + default translations. Apps mount it
|
|
4
|
+
// in createKumikoApp({ clientFeatures: [personalAccessTokensClient()] }); the
|
|
5
|
+
// server registers the screen dormant (r.screen) and the app places it via
|
|
6
|
+
// r.nav.
|
|
7
|
+
|
|
8
|
+
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
9
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
10
|
+
import { PAT_FEATURE, PAT_SCREEN_ID } from "../constants";
|
|
11
|
+
import { defaultTranslations } from "./i18n";
|
|
12
|
+
import { PatTokensScreen } from "./pat-tokens-screen";
|
|
13
|
+
|
|
14
|
+
export type PersonalAccessTokensClientOptions = {
|
|
15
|
+
/** Key-wise overrides over the default bundles (de/en). */
|
|
16
|
+
readonly translations?: TranslationsByLocale;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function personalAccessTokensClient(
|
|
20
|
+
options?: PersonalAccessTokensClientOptions,
|
|
21
|
+
): ClientFeatureDefinition {
|
|
22
|
+
return {
|
|
23
|
+
name: PAT_FEATURE,
|
|
24
|
+
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
25
|
+
components: {
|
|
26
|
+
[PAT_SCREEN_ID]: PatTokensScreen,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
|
|
4
|
+
export const defaultTranslations: TranslationsByLocale = {
|
|
5
|
+
de: {
|
|
6
|
+
"pat.title": "Personal Access Tokens",
|
|
7
|
+
"pat.create.title": "Neuen Token erstellen",
|
|
8
|
+
"pat.create.name": "Name",
|
|
9
|
+
"pat.create.namePlaceholder": "z. B. CLI-Automatisierung",
|
|
10
|
+
"pat.create.scopes": "Berechtigungen",
|
|
11
|
+
"pat.create.submit": "Token erstellen",
|
|
12
|
+
"pat.create.needName": "Bitte einen Namen vergeben.",
|
|
13
|
+
"pat.create.needScope": "Bitte mindestens eine Berechtigung wählen.",
|
|
14
|
+
"pat.created.title": "Token erstellt — jetzt kopieren",
|
|
15
|
+
"pat.created.hint": "Dieser Token wird nur einmal angezeigt. Kopiere ihn jetzt.",
|
|
16
|
+
"pat.created.dismiss": "Verstanden",
|
|
17
|
+
"pat.list.title": "Deine Tokens",
|
|
18
|
+
"pat.list.empty": "Noch keine Tokens.",
|
|
19
|
+
"pat.list.created": "Erstellt",
|
|
20
|
+
"pat.list.revoked": "Widerrufen",
|
|
21
|
+
"pat.list.expired": "Abgelaufen",
|
|
22
|
+
"pat.list.revoke": "Widerrufen",
|
|
23
|
+
"pat.error.generic": "Aktion fehlgeschlagen.",
|
|
24
|
+
},
|
|
25
|
+
en: {
|
|
26
|
+
"pat.title": "Personal Access Tokens",
|
|
27
|
+
"pat.create.title": "Create a new token",
|
|
28
|
+
"pat.create.name": "Name",
|
|
29
|
+
"pat.create.namePlaceholder": "e.g. CLI automation",
|
|
30
|
+
"pat.create.scopes": "Permissions",
|
|
31
|
+
"pat.create.submit": "Create token",
|
|
32
|
+
"pat.create.needName": "Please enter a name.",
|
|
33
|
+
"pat.create.needScope": "Please select at least one permission.",
|
|
34
|
+
"pat.created.title": "Token created — copy it now",
|
|
35
|
+
"pat.created.hint": "This token is shown only once. Copy it now.",
|
|
36
|
+
"pat.created.dismiss": "Got it",
|
|
37
|
+
"pat.list.title": "Your tokens",
|
|
38
|
+
"pat.list.empty": "No tokens yet.",
|
|
39
|
+
"pat.list.created": "Created",
|
|
40
|
+
"pat.list.revoked": "Revoked",
|
|
41
|
+
"pat.list.expired": "Expired",
|
|
42
|
+
"pat.list.revoke": "Revoke",
|
|
43
|
+
"pat.error.generic": "Action failed.",
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// PatTokensScreen — logged-in self-service for Personal Access Tokens: mint a
|
|
3
|
+
// token (name + scope toggles, plaintext shown once), list your tokens and
|
|
4
|
+
// revoke them. The feature registers it dormant (r.screen); the app places it
|
|
5
|
+
// via r.nav. Imports only from ../constants (pure) — no server pull-in.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
useDispatcher,
|
|
9
|
+
usePrimitives,
|
|
10
|
+
useQuery,
|
|
11
|
+
useTranslation,
|
|
12
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
13
|
+
import { type ReactNode, useState } from "react";
|
|
14
|
+
import { PatHandlers, PatQueries } from "../constants";
|
|
15
|
+
|
|
16
|
+
type ScopeOption = { readonly name: string; readonly label: string };
|
|
17
|
+
type TokenRow = {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly prefix: string;
|
|
21
|
+
readonly scopes: readonly string[];
|
|
22
|
+
readonly createdAt: string | null;
|
|
23
|
+
readonly expiresAt: string | null;
|
|
24
|
+
readonly revokedAt: string | null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function isExpired(expiresAt: string | null): boolean {
|
|
28
|
+
return expiresAt !== null && new Date(expiresAt).getTime() <= Date.now();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function PatTokensScreen(): ReactNode {
|
|
32
|
+
const t = useTranslation();
|
|
33
|
+
const { Section, Heading, Field, Input, Button, Banner, Card, Text } = usePrimitives();
|
|
34
|
+
const dispatcher = useDispatcher();
|
|
35
|
+
|
|
36
|
+
const scopesQuery = useQuery<readonly ScopeOption[]>(PatQueries.availableScopes, {});
|
|
37
|
+
const listQuery = useQuery<readonly TokenRow[]>(PatQueries.mine, {});
|
|
38
|
+
|
|
39
|
+
const [name, setName] = useState("");
|
|
40
|
+
const [selected, setSelected] = useState<readonly string[]>([]);
|
|
41
|
+
const [minted, setMinted] = useState<{ token: string; prefix: string } | null>(null);
|
|
42
|
+
const [error, setError] = useState<string | null>(null);
|
|
43
|
+
const [busy, setBusy] = useState(false);
|
|
44
|
+
|
|
45
|
+
const scopes = scopesQuery.data ?? [];
|
|
46
|
+
const tokens = listQuery.data ?? [];
|
|
47
|
+
|
|
48
|
+
const toggle = (scope: string): void =>
|
|
49
|
+
setSelected((cur) => (cur.includes(scope) ? cur.filter((s) => s !== scope) : [...cur, scope]));
|
|
50
|
+
|
|
51
|
+
const create = async (): Promise<void> => {
|
|
52
|
+
if (name.trim() === "") return setError(t("pat.create.needName"));
|
|
53
|
+
if (selected.length === 0) return setError(t("pat.create.needScope"));
|
|
54
|
+
setBusy(true);
|
|
55
|
+
setError(null);
|
|
56
|
+
const res = await dispatcher.write(PatHandlers.create, {
|
|
57
|
+
name: name.trim(),
|
|
58
|
+
scopes: selected,
|
|
59
|
+
});
|
|
60
|
+
setBusy(false);
|
|
61
|
+
if (!res.isSuccess) return setError(t("pat.error.generic"));
|
|
62
|
+
const data = res.data as { token: string; prefix: string };
|
|
63
|
+
setMinted({ token: data.token, prefix: data.prefix });
|
|
64
|
+
setName("");
|
|
65
|
+
setSelected([]);
|
|
66
|
+
void listQuery.refetch?.();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const revoke = async (id: string): Promise<void> => {
|
|
70
|
+
const res = await dispatcher.write(PatHandlers.revoke, { id });
|
|
71
|
+
if (!res.isSuccess) return setError(t("pat.error.generic"));
|
|
72
|
+
void listQuery.refetch?.();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const status = (row: TokenRow): string => {
|
|
76
|
+
if (row.revokedAt !== null) return t("pat.list.revoked");
|
|
77
|
+
if (isExpired(row.expiresAt)) return t("pat.list.expired");
|
|
78
|
+
return "";
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Section>
|
|
83
|
+
<Heading>{t("pat.title")}</Heading>
|
|
84
|
+
|
|
85
|
+
{minted && (
|
|
86
|
+
<Banner
|
|
87
|
+
variant="info"
|
|
88
|
+
actions={
|
|
89
|
+
<Button variant="secondary" onClick={() => setMinted(null)}>
|
|
90
|
+
{t("pat.created.dismiss")}
|
|
91
|
+
</Button>
|
|
92
|
+
}
|
|
93
|
+
>
|
|
94
|
+
<Heading>{t("pat.created.title")}</Heading>
|
|
95
|
+
<Text>{minted.token}</Text>
|
|
96
|
+
<Text>{t("pat.created.hint")}</Text>
|
|
97
|
+
</Banner>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{error && <Banner variant="error">{error}</Banner>}
|
|
101
|
+
|
|
102
|
+
<Section>
|
|
103
|
+
<Heading>{t("pat.create.title")}</Heading>
|
|
104
|
+
<Field id="pat-name" label={t("pat.create.name")} required>
|
|
105
|
+
<Input
|
|
106
|
+
kind="text"
|
|
107
|
+
id="pat-name"
|
|
108
|
+
name="name"
|
|
109
|
+
value={name}
|
|
110
|
+
onChange={setName}
|
|
111
|
+
placeholder={t("pat.create.namePlaceholder")}
|
|
112
|
+
disabled={busy}
|
|
113
|
+
/>
|
|
114
|
+
</Field>
|
|
115
|
+
<Text>{t("pat.create.scopes")}</Text>
|
|
116
|
+
{scopes.map((s) => (
|
|
117
|
+
<Button
|
|
118
|
+
key={s.name}
|
|
119
|
+
variant={selected.includes(s.name) ? "primary" : "secondary"}
|
|
120
|
+
onClick={() => toggle(s.name)}
|
|
121
|
+
disabled={busy}
|
|
122
|
+
>
|
|
123
|
+
{s.label}
|
|
124
|
+
</Button>
|
|
125
|
+
))}
|
|
126
|
+
<Button type="submit" variant="primary" onClick={create} loading={busy} disabled={busy}>
|
|
127
|
+
{t("pat.create.submit")}
|
|
128
|
+
</Button>
|
|
129
|
+
</Section>
|
|
130
|
+
|
|
131
|
+
<Section>
|
|
132
|
+
<Heading>{t("pat.list.title")}</Heading>
|
|
133
|
+
{tokens.length === 0 && <Text>{t("pat.list.empty")}</Text>}
|
|
134
|
+
{tokens.map((row) => (
|
|
135
|
+
<Card key={row.id}>
|
|
136
|
+
<Text>
|
|
137
|
+
{row.name} — {row.prefix}… {status(row)}
|
|
138
|
+
</Text>
|
|
139
|
+
<Text>{row.scopes.join(", ")}</Text>
|
|
140
|
+
{row.revokedAt === null && (
|
|
141
|
+
<Button variant="danger" onClick={() => revoke(row.id)}>
|
|
142
|
+
{t("pat.list.revoke")}
|
|
143
|
+
</Button>
|
|
144
|
+
)}
|
|
145
|
+
</Card>
|
|
146
|
+
))}
|
|
147
|
+
</Section>
|
|
148
|
+
</Section>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
2
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
4
3
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
4
|
import {
|
|
@@ -7,6 +6,7 @@ import {
|
|
|
7
6
|
type TestStack,
|
|
8
7
|
unsafeCreateEntityTable,
|
|
9
8
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
9
|
+
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
10
10
|
import type { RendererContext } from "../../renderer-foundation";
|
|
11
11
|
import { SYSTEM_TENANT_ID } from "../../template-resolver/constants";
|
|
12
12
|
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
@@ -35,7 +35,7 @@ function rendererCtx(): RendererContext {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
async function seedPlainNotificationTemplate(content: string): Promise<void> {
|
|
38
|
-
await
|
|
38
|
+
await seedRow(db, templateResourcesTable, {
|
|
39
39
|
tenantId: SYSTEM_TENANT_ID,
|
|
40
40
|
slug: "welcome-mail",
|
|
41
41
|
kind: "notification",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// expired/revoked rows go, live rows stay, batching + signal work.
|
|
6
6
|
|
|
7
7
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
-
import {
|
|
8
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
9
9
|
import { sql } from "@cosmicdrift/kumiko-framework/db";
|
|
10
10
|
import type { AppContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
11
11
|
import {
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
testTenantId,
|
|
15
15
|
unsafeCreateEntityTable,
|
|
16
16
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
17
|
-
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
17
|
+
import { resetTestTables, seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
18
18
|
import { createUserFeature } from "../../user/feature";
|
|
19
19
|
import { userEntity } from "../../user/schema/user";
|
|
20
20
|
import { createSessionsFeature } from "../feature";
|
|
@@ -79,7 +79,7 @@ async function seedSession(opts: {
|
|
|
79
79
|
const past = sql`now() - ${sql.raw(`interval '${opts.ageDays} days'`)}`;
|
|
80
80
|
const future = sql`now() + ${sql.raw(`interval '30 days'`)}`;
|
|
81
81
|
|
|
82
|
-
await
|
|
82
|
+
await seedRow(stack.db, userSessionTable, {
|
|
83
83
|
id: opts.id,
|
|
84
84
|
tenantId: TENANT,
|
|
85
85
|
userId: opts.userId,
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
asRawClient,
|
|
4
|
-
insertOne,
|
|
5
|
-
selectMany,
|
|
6
|
-
updateMany,
|
|
7
|
-
} from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
3
|
import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
9
4
|
import { createRegistry, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
5
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
@@ -18,6 +13,7 @@ import {
|
|
|
18
13
|
testTenantId,
|
|
19
14
|
unsafeCreateEntityTable,
|
|
20
15
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
16
|
+
import { seedRow, updateRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
21
17
|
import { Temporal } from "temporal-polyfill";
|
|
22
18
|
import { createUserFeature } from "../../user/feature";
|
|
23
19
|
import { createSessionsFeature } from "../feature";
|
|
@@ -62,7 +58,7 @@ const SID = "00000000-0000-0000-0000-000000000001";
|
|
|
62
58
|
|
|
63
59
|
async function insertRevokedSession(db: DbConnection): Promise<void> {
|
|
64
60
|
const now = Temporal.Now.instant();
|
|
65
|
-
await
|
|
61
|
+
await seedRow(db, userSessionTable, {
|
|
66
62
|
id: SID,
|
|
67
63
|
tenantId: TENANT,
|
|
68
64
|
userId: "user-1",
|
|
@@ -71,7 +67,7 @@ async function insertRevokedSession(db: DbConnection): Promise<void> {
|
|
|
71
67
|
ip: "1.2.3.4",
|
|
72
68
|
userAgent: "test-agent",
|
|
73
69
|
});
|
|
74
|
-
await
|
|
70
|
+
await updateRows(db, userSessionTable, { revokedAt: now }, { id: SID, revokedAt: null });
|
|
75
71
|
}
|
|
76
72
|
|
|
77
73
|
describe("sessions / read_user_sessions survives projection rebuild", () => {
|
|
@@ -93,6 +89,6 @@ describe("sessions / read_user_sessions survives projection rebuild", () => {
|
|
|
93
89
|
|
|
94
90
|
const rows = await selectMany(testDb.db, userSessionTable, {});
|
|
95
91
|
expect(rows.length).toBe(1);
|
|
96
|
-
expect(
|
|
92
|
+
expect(rows[0]?.revokedAt).not.toBeNull();
|
|
97
93
|
});
|
|
98
94
|
});
|