@cosmicdrift/kumiko-bundled-features 0.105.2 → 0.109.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 (116) 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 +205 -0
  48. package/src/personal-access-tokens/__tests__/scopes.test.ts +42 -0
  49. package/src/personal-access-tokens/constants.ts +33 -0
  50. package/src/personal-access-tokens/feature.ts +78 -0
  51. package/src/personal-access-tokens/handlers/available-scopes.query.ts +21 -0
  52. package/src/personal-access-tokens/handlers/create.write.ts +41 -0
  53. package/src/personal-access-tokens/handlers/list.query.ts +47 -0
  54. package/src/personal-access-tokens/handlers/revoke.write.ts +37 -0
  55. package/src/personal-access-tokens/hash.ts +17 -0
  56. package/src/personal-access-tokens/index.ts +45 -0
  57. package/src/personal-access-tokens/resolver.ts +58 -0
  58. package/src/personal-access-tokens/roles.ts +29 -0
  59. package/src/personal-access-tokens/schema/api-token.ts +56 -0
  60. package/src/personal-access-tokens/scopes.ts +43 -0
  61. package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
  62. package/src/personal-access-tokens/web/i18n.ts +69 -0
  63. package/src/personal-access-tokens/web/index.ts +7 -0
  64. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +251 -0
  65. package/src/renderer-simple/__tests__/template-resolver.integration.test.ts +2 -2
  66. package/src/sessions/__tests__/cleanup.integration.test.ts +3 -3
  67. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +5 -9
  68. package/src/sessions/__tests__/sessions.integration.test.ts +14 -9
  69. package/src/sessions/schema/user-session.ts +8 -2
  70. package/src/sessions/session-callbacks.ts +9 -3
  71. package/src/subscription-stripe/__tests__/runtime.test.ts +14 -24
  72. package/src/tags/__tests__/tags.integration.test.ts +15 -0
  73. package/src/tags/handlers/assign-tag.write.ts +13 -3
  74. package/src/tags/schemas.ts +10 -4
  75. package/src/template-resolver/__tests__/template-resolver.integration.test.ts +2 -2
  76. package/src/template-resolver/testing.ts +4 -3
  77. package/src/tier-engine/feature.ts +22 -10
  78. package/src/tier-engine/handlers/set-tenant-tier.write.ts +11 -1
  79. package/src/user/__tests__/stream-tenant-backfill.integration.test.ts +186 -0
  80. package/src/user/db/queries/stream-tenant-backfill.ts +112 -0
  81. package/src/user/index.ts +4 -0
  82. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +4 -4
  83. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +4 -4
  84. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -4
  85. package/src/user-data-rights/__tests__/download.integration.test.ts +7 -7
  86. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +5 -10
  87. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +84 -0
  88. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +3 -3
  89. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +3 -2
  90. package/src/user-data-rights/__tests__/forget-test-helpers.ts +3 -2
  91. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +3 -3
  92. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +10 -6
  93. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +4 -4
  94. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +4 -4
  95. package/src/user-data-rights/__tests__/request-export.integration.test.ts +4 -9
  96. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +7 -3
  97. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +8 -4
  98. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +12 -17
  99. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +9 -5
  100. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +3 -3
  101. package/src/user-data-rights/handlers/download-attempt-list.query.ts +1 -1
  102. package/src/user-data-rights/handlers/download-by-job.query.ts +4 -6
  103. package/src/user-data-rights/handlers/export-job-detail.query.ts +1 -0
  104. package/src/user-data-rights/handlers/export-job-list.query.ts +1 -0
  105. package/src/user-data-rights/lib/update-user-lifecycle.ts +8 -24
  106. package/src/user-data-rights/run-forget-cleanup.ts +8 -1
  107. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +7 -1
  108. package/src/user-data-rights/web/i18n.ts +0 -10
  109. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +3 -2
  110. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +102 -54
  111. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +53 -15
  112. package/src/user-profile/__tests__/profile-screen.test.tsx +9 -0
  113. package/src/user-profile/handlers/change-email.write.ts +11 -8
  114. package/src/user-profile/web/profile-screen.tsx +10 -8
  115. package/src/auth-email-password/stream-tenant.ts +0 -31
  116. package/src/user-data-rights-defaults/db/queries/user-hook.ts +0 -17
@@ -0,0 +1,47 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { z } from "zod";
4
+ import { apiTokenTable } from "../schema/api-token";
5
+
6
+ // The caller's own tokens — metadata only, never the hash or plaintext.
7
+ // Includes revoked/expired rows so the UI can show history; `prefix` is the
8
+ // only fragment of the secret ever exposed.
9
+ export const listPatQuery = defineQueryHandler({
10
+ name: "mine",
11
+ schema: z.object({}),
12
+ access: { openToAll: true },
13
+ handler: async (query, ctx) => {
14
+ const rows = await selectMany<{
15
+ id: string;
16
+ name: string;
17
+ prefix: string;
18
+ scopes: string;
19
+ createdAt: unknown;
20
+ expiresAt: unknown;
21
+ revokedAt: unknown;
22
+ }>(
23
+ ctx.db,
24
+ apiTokenTable,
25
+ { userId: query.user.id },
26
+ { orderBy: { col: "createdAt", direction: "desc" } },
27
+ );
28
+ return rows.map((r) => ({
29
+ id: r.id,
30
+ name: r.name,
31
+ prefix: r.prefix,
32
+ scopes: parseScopeNames(r.scopes),
33
+ createdAt: r.createdAt,
34
+ expiresAt: r.expiresAt,
35
+ revokedAt: r.revokedAt,
36
+ }));
37
+ },
38
+ });
39
+
40
+ function parseScopeNames(raw: string): string[] {
41
+ try {
42
+ const parsed: unknown = JSON.parse(raw);
43
+ return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [];
44
+ } catch {
45
+ return [];
46
+ }
47
+ }
@@ -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,43 @@
1
+ // @runtime client
2
+ // Pure scope helpers + types — client-marked so the web screen (web/) may import
3
+ // parseGrant without pulling the feature's server runtime barrel.
4
+ //
5
+ // App-declared scopes — two axes, like GitHub fine-grained PATs: WHICH API
6
+ // (the domain, keyed here) × the permission LEVEL (read vs read+write). Each
7
+ // domain declares its read-QN globs and (optionally) its write-QN globs. A
8
+ // domain may span features (e.g. a "miete" domain granting ledger + folders).
9
+ export type PatScopeDef = {
10
+ readonly label: string;
11
+ readonly read: readonly string[];
12
+ // Omit for a read-only domain — the UI then offers only "no access" / "read".
13
+ readonly write?: readonly string[];
14
+ };
15
+
16
+ export type PatScopeConfig = Readonly<Record<string, PatScopeDef>>;
17
+
18
+ export type PatLevel = "read" | "write";
19
+
20
+ // A granted scope is the string "<domain>:<level>" (e.g. "credit:write"). The
21
+ // domain key never contains a colon, so split on the LAST one.
22
+ export function parseGrant(grant: string): { domain: string; level: string } | null {
23
+ const idx = grant.lastIndexOf(":");
24
+ if (idx <= 0) return null;
25
+ return { domain: grant.slice(0, idx), level: grant.slice(idx + 1) };
26
+ }
27
+
28
+ // Expand granted "<domain>:<level>" entries into the union of QN globs: read
29
+ // always grants the read QNs; write additionally grants the write QNs. Unknown
30
+ // domains contribute nothing (fail-closed — a scope dropped from config after a
31
+ // token was minted silently loses that capability).
32
+ export function expandScopes(config: PatScopeConfig, granted: readonly string[]): string[] {
33
+ const out = new Set<string>();
34
+ for (const grant of granted) {
35
+ const parsed = parseGrant(grant);
36
+ if (!parsed) continue;
37
+ const def = config[parsed.domain];
38
+ if (!def) continue;
39
+ for (const q of def.read) out.add(q);
40
+ if (parsed.level === "write") for (const q of def.write ?? []) out.add(q);
41
+ }
42
+ return [...out];
43
+ }
@@ -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,69 @@
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.subtitle":
9
+ "Wähle Berechtigungen und eine Gültigkeit. Der Token wird nur einmal angezeigt.",
10
+ "pat.create.name": "Name",
11
+ "pat.create.namePlaceholder": "z. B. CLI-Automatisierung",
12
+ "pat.create.scopes": "Zugriff pro Bereich",
13
+ "pat.level.none": "Kein Zugriff",
14
+ "pat.level.read": "Lesen",
15
+ "pat.level.write": "Lesen & Schreiben",
16
+ "pat.create.expiry": "Gültig bis",
17
+ "pat.create.submit": "Token erstellen",
18
+ "pat.create.needName": "Bitte einen Namen vergeben.",
19
+ "pat.create.needScope": "Bitte mindestens eine Berechtigung wählen.",
20
+ "pat.expiry.30d": "30 Tage",
21
+ "pat.expiry.90d": "90 Tage",
22
+ "pat.expiry.1y": "1 Jahr",
23
+ "pat.expiry.never": "Unbegrenzt",
24
+ "pat.created.hint":
25
+ "Kopiere diesen Token jetzt — er wird aus Sicherheitsgründen nur dieses eine Mal angezeigt.",
26
+ "pat.created.copy": "Kopieren",
27
+ "pat.created.copied": "Kopiert ✓",
28
+ "pat.created.dismiss": "Fertig",
29
+ "pat.list.title": "Deine Tokens",
30
+ "pat.list.empty": "Noch keine Tokens.",
31
+ "pat.list.revoke": "Widerrufen",
32
+ "pat.list.revoked": "Widerrufen",
33
+ "pat.list.validUntil": "Gültig bis {date}",
34
+ "pat.list.neverExpires": "Unbegrenzt gültig",
35
+ "pat.list.created": "Erstellt {date}",
36
+ "pat.error.generic": "Aktion fehlgeschlagen.",
37
+ },
38
+ en: {
39
+ "pat.title": "Personal Access Tokens",
40
+ "pat.create.title": "Create a new token",
41
+ "pat.create.subtitle": "Pick permissions and an expiration. The token is shown only once.",
42
+ "pat.create.name": "Name",
43
+ "pat.create.namePlaceholder": "e.g. CLI automation",
44
+ "pat.create.scopes": "Per-API access",
45
+ "pat.level.none": "No access",
46
+ "pat.level.read": "Read",
47
+ "pat.level.write": "Read & write",
48
+ "pat.create.expiry": "Expiration",
49
+ "pat.create.submit": "Create token",
50
+ "pat.create.needName": "Please enter a name.",
51
+ "pat.create.needScope": "Please select at least one permission.",
52
+ "pat.expiry.30d": "30 days",
53
+ "pat.expiry.90d": "90 days",
54
+ "pat.expiry.1y": "1 year",
55
+ "pat.expiry.never": "No expiration",
56
+ "pat.created.hint": "Copy this token now — for security it is shown only this once.",
57
+ "pat.created.copy": "Copy",
58
+ "pat.created.copied": "Copied ✓",
59
+ "pat.created.dismiss": "Done",
60
+ "pat.list.title": "Your tokens",
61
+ "pat.list.empty": "No tokens yet.",
62
+ "pat.list.revoke": "Revoke",
63
+ "pat.list.revoked": "Revoked",
64
+ "pat.list.validUntil": "Valid until {date}",
65
+ "pat.list.neverExpires": "Never expires",
66
+ "pat.list.created": "Created {date}",
67
+ "pat.error.generic": "Action failed.",
68
+ },
69
+ };
@@ -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";