@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.
Files changed (115) hide show
  1. package/package.json +8 -6
  2. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +3 -2
  3. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +5 -9
  4. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +3 -2
  5. package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +3 -2
  6. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +4 -3
  7. package/src/auth-email-password/handlers/change-password.write.ts +11 -11
  8. package/src/auth-email-password/handlers/confirm-token-flow.ts +33 -104
  9. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +44 -0
  10. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +14 -1
  11. package/src/auth-email-password/web/auth-gate.tsx +3 -4
  12. package/src/auth-email-password/web/invite-accept-screen.tsx +7 -3
  13. package/src/auth-email-password/web/session.tsx +5 -1
  14. package/src/auth-email-password/web/user-menu.tsx +1 -1
  15. package/src/config/__tests__/deserialize-value.test.ts +3 -6
  16. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +65 -0
  17. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +4 -3
  18. package/src/custom-fields/constants.ts +1 -0
  19. package/src/custom-fields/handlers/delete-system-field.write.ts +2 -1
  20. package/src/custom-fields/handlers/delete-tenant-field.write.ts +2 -1
  21. package/src/custom-fields/wire-for-entity.ts +104 -77
  22. package/src/data-retention/__tests__/parse-override.test.ts +11 -6
  23. package/src/data-retention/resolve-for-tenant.ts +16 -4
  24. package/src/data-retention/run-retention-cleanup.ts +72 -10
  25. package/src/delivery/attempt-log.ts +22 -10
  26. package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +4 -4
  27. package/src/folders/__tests__/folders.integration.test.ts +26 -0
  28. package/src/folders/feature.ts +4 -2
  29. package/src/folders/handlers/delete-folder.write.ts +40 -0
  30. package/src/folders/web/__tests__/folder-manager.test.tsx +24 -0
  31. package/src/folders/web/folder-manager.tsx +18 -13
  32. package/src/folders/web/i18n.ts +0 -2
  33. package/src/folders-user-data/__tests__/hooks.integration.test.ts +69 -0
  34. package/src/folders-user-data/hooks.ts +31 -8
  35. package/src/folders-user-data/index.ts +4 -4
  36. package/src/jobs/feature.ts +23 -21
  37. package/src/jobs/handlers/projection-rebuild.job.ts +14 -4
  38. package/src/ledger/__tests__/ledger.integration.test.ts +44 -0
  39. package/src/ledger/__tests__/reports.test.ts +20 -0
  40. package/src/ledger/entity.ts +1 -1
  41. package/src/ledger/handlers/confirm-schedule-period.write.ts +29 -13
  42. package/src/ledger/handlers/reverse-transaction.write.ts +44 -5
  43. package/src/ledger/recurring.ts +18 -9
  44. package/src/ledger/schemas.ts +4 -1
  45. package/src/ledger/web/index.ts +3 -2
  46. package/src/managed-pages/__tests__/branding-coerce.test.ts +2 -6
  47. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +204 -0
  48. package/src/personal-access-tokens/constants.ts +33 -0
  49. package/src/personal-access-tokens/feature.ts +78 -0
  50. package/src/personal-access-tokens/handlers/available-scopes.query.ts +15 -0
  51. package/src/personal-access-tokens/handlers/create.write.ts +41 -0
  52. package/src/personal-access-tokens/handlers/list.query.ts +47 -0
  53. package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
  54. package/src/personal-access-tokens/hash.ts +17 -0
  55. package/src/personal-access-tokens/index.ts +45 -0
  56. package/src/personal-access-tokens/resolver.ts +58 -0
  57. package/src/personal-access-tokens/roles.ts +29 -0
  58. package/src/personal-access-tokens/schema/api-token.ts +56 -0
  59. package/src/personal-access-tokens/scopes.ts +22 -0
  60. package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
  61. package/src/personal-access-tokens/web/i18n.ts +45 -0
  62. package/src/personal-access-tokens/web/index.ts +7 -0
  63. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +150 -0
  64. package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
  65. package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
  66. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
  67. package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
  68. package/src/sessions/schema/user-session.ts +8 -2
  69. package/src/sessions/session-callbacks.ts +9 -3
  70. package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
  71. package/src/tags/__tests__/tags.integration.test.ts +15 -0
  72. package/src/tags/handlers/assign-tag.write.ts +13 -3
  73. package/src/tags/schemas.ts +10 -4
  74. package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
  75. package/src/template-resolver/testing.ts +4 -3
  76. package/src/tier-engine/feature.ts +22 -10
  77. package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
  78. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
  79. package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
  80. package/src/user/index.ts +4 -0
  81. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
  82. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
  83. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
  84. package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
  85. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
  86. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
  87. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
  88. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
  89. package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
  90. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
  91. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
  92. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
  93. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
  94. package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
  95. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
  96. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
  97. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
  98. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
  99. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
  100. package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
  101. package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
  102. package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
  103. package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
  104. package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
  105. package/src/user-data-rights/run-forget-cleanup.ts +8 -1
  106. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
  107. package/src/user-data-rights/web/i18n.ts +0 -10
  108. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
  109. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
  110. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
  111. package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
  112. package/src/user-profile/handlers/change-email.write.ts +11 -8
  113. package/src/user-profile/web/profile-screen.tsx +10 -8
  114. package/src/auth-email-password/stream-tenant.ts +0 -31
  115. 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,7 @@
1
+ // @runtime client
2
+ export {
3
+ type PersonalAccessTokensClientOptions,
4
+ personalAccessTokensClient,
5
+ } from "./client-plugin";
6
+ export { defaultTranslations } from "./i18n";
7
+ export { PatTokensScreen } from "./pat-tokens-screen";
@@ -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 insertOne(db, templateResourcesTable, {
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 { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
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 insertOne(stack.db, userSessionTable, {
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 insertOne(db, userSessionTable, {
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 updateMany(db, userSessionTable, { revokedAt: now }, { id: SID, revokedAt: null });
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((rows[0] as { revokedAt: unknown }).revokedAt).not.toBeNull();
92
+ expect(rows[0]?.revokedAt).not.toBeNull();
97
93
  });
98
94
  });