@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.
- 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 +205 -0
- package/src/personal-access-tokens/__tests__/scopes.test.ts +42 -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 +21 -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 +43 -0
- package/src/personal-access-tokens/web/client-plugin.tsx +29 -0
- package/src/personal-access-tokens/web/i18n.ts +69 -0
- package/src/personal-access-tokens/web/index.ts +7 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +251 -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
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
transactionExecutor,
|
|
14
14
|
transactionTable,
|
|
15
15
|
} from "../executor";
|
|
16
|
-
import { scheduleReference } from "../recurring";
|
|
16
|
+
import { findReversedIds, isoMonth, scheduleReference } from "../recurring";
|
|
17
17
|
import { type ConfirmSchedulePeriodPayload, confirmSchedulePeriodPayloadSchema } from "../schemas";
|
|
18
18
|
|
|
19
19
|
// confirm-schedule-period — turn ONE projected period of a schedule into a posted,
|
|
@@ -42,6 +42,13 @@ export function createConfirmSchedulePeriodHandler(
|
|
|
42
42
|
);
|
|
43
43
|
if (!schedule) return writeFailure(new NotFoundError("schedule", payload.scheduleId));
|
|
44
44
|
|
|
45
|
+
if (
|
|
46
|
+
payload.period < isoMonth(String(schedule["startDate"])) ||
|
|
47
|
+
(schedule["endDate"] != null && payload.period > isoMonth(String(schedule["endDate"])))
|
|
48
|
+
) {
|
|
49
|
+
return writeFailure(new NotFoundError("schedule-period", payload.period));
|
|
50
|
+
}
|
|
51
|
+
|
|
45
52
|
const debitAccountId = String(schedule["debitAccountId"]);
|
|
46
53
|
const creditAccountId = String(schedule["creditAccountId"]);
|
|
47
54
|
for (const accountId of [debitAccountId, creditAccountId]) {
|
|
@@ -51,24 +58,33 @@ export function createConfirmSchedulePeriodHandler(
|
|
|
51
58
|
|
|
52
59
|
const reference = scheduleReference(payload.scheduleId, payload.period);
|
|
53
60
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
61
|
+
// Two targeted reads instead of a full-tenant transaction scan: (1) any
|
|
62
|
+
// booking that already carries this exact schedule-period reference —
|
|
63
|
+
// normally 0 or 1 row, never O(tenant's-full-history); (2) any Storno
|
|
64
|
+
// that mirrors one of those candidates (a tx is reversed when ANOTHER
|
|
65
|
+
// tx's `reference` names its id).
|
|
57
66
|
// ponytail: read-then-write, so two confirms racing the same period could
|
|
58
67
|
// double-book; add a unique index on (tenantId, reference) when concurrent
|
|
59
68
|
// confirms become real.
|
|
60
|
-
const
|
|
69
|
+
const candidates = await selectMany(ctx.db.raw, transactionTable, {
|
|
61
70
|
tenantId: event.user.tenantId,
|
|
71
|
+
reference,
|
|
62
72
|
});
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
const candidateIds = candidates.map((r) => String(r["id"]));
|
|
74
|
+
const stornos =
|
|
75
|
+
candidateIds.length > 0
|
|
76
|
+
? await selectMany(ctx.db.raw, transactionTable, {
|
|
77
|
+
tenantId: event.user.tenantId,
|
|
78
|
+
reference: { in: candidateIds },
|
|
79
|
+
})
|
|
80
|
+
: [];
|
|
81
|
+
const reversedIds = findReversedIds(
|
|
82
|
+
[...candidates, ...stornos].map((r) => ({
|
|
83
|
+
id: String(r["id"]),
|
|
84
|
+
reference: r["reference"] === null ? null : String(r["reference"]),
|
|
85
|
+
})),
|
|
71
86
|
);
|
|
87
|
+
const active = candidates.find((r) => !reversedIds.has(String(r["id"])));
|
|
72
88
|
if (active) {
|
|
73
89
|
const ok: WriteResult<{ id: string; alreadyBooked: true }> = {
|
|
74
90
|
isSuccess: true,
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
1
2
|
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ConflictError,
|
|
5
|
+
NotFoundError,
|
|
6
|
+
UnprocessableError,
|
|
7
|
+
writeFailure,
|
|
8
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
3
9
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
4
10
|
import { DEFAULT_LEDGER_ACCESS } from "../constants";
|
|
5
|
-
import { transactionExecutor } from "../executor";
|
|
6
|
-
import
|
|
11
|
+
import { transactionExecutor, transactionTable } from "../executor";
|
|
12
|
+
import { normalizeLines } from "../reports";
|
|
7
13
|
import { type ReverseTransactionPayload, reverseTransactionPayloadSchema } from "../schemas";
|
|
8
14
|
|
|
9
15
|
// reverse-transaction (Storno) — the ONLY correction path for a posted entry.
|
|
@@ -24,8 +30,41 @@ export function createReverseTransactionHandler(
|
|
|
24
30
|
const original = await transactionExecutor.detail({ id: payload.id }, event.user, ctx.db);
|
|
25
31
|
if (!original) return writeFailure(new NotFoundError("transaction", payload.id));
|
|
26
32
|
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
// Only a posted entry contributes to the books (rawBalances skips
|
|
34
|
+
// anything else) — reversing a draft would book a real, balanced
|
|
35
|
+
// Storno entry against a booking that was never counted, creating a
|
|
36
|
+
// phantom balance with no corresponding original.
|
|
37
|
+
if (original["status"] !== "posted") {
|
|
38
|
+
return writeFailure(
|
|
39
|
+
new UnprocessableError("transaction_not_posted", {
|
|
40
|
+
details: { transactionId: payload.id, status: original["status"] },
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Dedup guard: two reverse() calls on the same original would each book
|
|
46
|
+
// an independently-balanced Storno (the global trial balance stays 0
|
|
47
|
+
// either way, masking the bug), doubling the per-account effect. The
|
|
48
|
+
// reference column ties a Storno back to its original 1:1.
|
|
49
|
+
const alreadyReversed = await selectMany(ctx.db.raw, transactionTable, {
|
|
50
|
+
tenantId: event.user.tenantId,
|
|
51
|
+
reference: payload.id,
|
|
52
|
+
});
|
|
53
|
+
if (alreadyReversed.length > 0) {
|
|
54
|
+
return writeFailure(
|
|
55
|
+
new ConflictError({
|
|
56
|
+
message: "transaction already reversed",
|
|
57
|
+
details: { transactionId: payload.id },
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// jsonb `lines` may surface as a parsed array or a JSON string depending
|
|
63
|
+
// on the driver path — normalizeLines handles both (see reports.ts).
|
|
64
|
+
const lines = normalizeLines(original["lines"]).map((l) => ({
|
|
65
|
+
accountId: l.accountId,
|
|
66
|
+
amount: -l.amount,
|
|
67
|
+
}));
|
|
29
68
|
|
|
30
69
|
return transactionExecutor.create(
|
|
31
70
|
{
|
package/src/ledger/recurring.ts
CHANGED
|
@@ -53,10 +53,26 @@ export function scheduleReference(scheduleId: string, period: string): string {
|
|
|
53
53
|
return `schedule:${scheduleId}:${period}`;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// A Storno mirror carries reference = the reversed tx's id, so any tx whose
|
|
57
|
+
// reference names another tx in the set marks that other tx reversed. Shared
|
|
58
|
+
// by mergeScheduleActuals (below) and confirm-schedule-period.write.ts's
|
|
59
|
+
// idempotency check (684/4) — same detection, kept in one place so a fix
|
|
60
|
+
// can't land in one call-site and drift from the other.
|
|
61
|
+
export function findReversedIds(
|
|
62
|
+
rows: readonly { readonly id: string; readonly reference: string | null }[],
|
|
63
|
+
): Set<string> {
|
|
64
|
+
const ids = new Set(rows.map((r) => r.id));
|
|
65
|
+
return new Set(
|
|
66
|
+
rows
|
|
67
|
+
.filter((r) => r.reference !== null && ids.has(r.reference))
|
|
68
|
+
.map((r) => r.reference as string),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
56
72
|
// "YYYY-MM" ⇄ a month index (year*12 + monthOfYear), so window math is pure
|
|
57
73
|
// integer arithmetic — no Date object (no-date-api guard) and no DST/timezone
|
|
58
74
|
// drift. isoMonth tolerates a full ISO date ("2026-01-15" → "2026-01").
|
|
59
|
-
function isoMonth(iso: string): string {
|
|
75
|
+
export function isoMonth(iso: string): string {
|
|
60
76
|
return iso.slice(0, 7);
|
|
61
77
|
}
|
|
62
78
|
|
|
@@ -105,14 +121,7 @@ export function mergeScheduleActuals(
|
|
|
105
121
|
transactions: readonly LedgerTxRow[],
|
|
106
122
|
asOf: string,
|
|
107
123
|
): ScheduleMonth[] {
|
|
108
|
-
const
|
|
109
|
-
// A Storno mirror carries reference = the reversed tx's id, so any tx whose
|
|
110
|
-
// reference names another tx marks that other tx reversed.
|
|
111
|
-
const reversedTxIds = new Set(
|
|
112
|
-
transactions
|
|
113
|
-
.filter((t) => t.reference != null && txIds.has(t.reference))
|
|
114
|
-
.map((t) => t.reference as string),
|
|
115
|
-
);
|
|
124
|
+
const reversedTxIds = findReversedIds(transactions);
|
|
116
125
|
const asOfIndex = monthIndex(isoMonth(asOf));
|
|
117
126
|
|
|
118
127
|
return projection.map((projected) => {
|
package/src/ledger/schemas.ts
CHANGED
|
@@ -57,7 +57,10 @@ export const confirmSchedulePeriodPayloadSchema = z.object({
|
|
|
57
57
|
scheduleId: z.string().min(1).max(64),
|
|
58
58
|
period: z.string().regex(/^\d{4}-\d{2}$/, "period must be YYYY-MM"),
|
|
59
59
|
amount: z.number().int().positive().optional(),
|
|
60
|
-
date: z
|
|
60
|
+
date: z
|
|
61
|
+
.string()
|
|
62
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD")
|
|
63
|
+
.optional(),
|
|
61
64
|
});
|
|
62
65
|
export type ConfirmSchedulePeriodPayload = z.infer<typeof confirmSchedulePeriodPayloadSchema>;
|
|
63
66
|
|
package/src/ledger/web/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
// Client-safe ledger surface: QN constants
|
|
3
|
-
// (projection + Soll/Ist merge),
|
|
2
|
+
// Client-safe ledger surface: QN constants, the pure recurring helpers
|
|
3
|
+
// (projection + Soll/Ist merge), and the Zod payload schemas (for client-side
|
|
4
|
+
// pre-validation before dispatch) — nothing else. The full `../ledger` entry
|
|
4
5
|
// re-exports the feature/handlers/executor, which pull bun-db/postgres — a
|
|
5
6
|
// browser bundle that imports from there fails on Node builtins. A client screen
|
|
6
7
|
// (e.g. a rent-cashflow view) imports the dispatch QNs + the pure forecast/merge
|
|
@@ -2,12 +2,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { type BrandingTokens, EMPTY_BRANDING } from "../../page-render";
|
|
3
3
|
import { coerceBranding } from "../branding";
|
|
4
4
|
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// non-string field must collapse to "" so a malformed/empty response renders
|
|
8
|
-
// the unbranded default rather than throwing — and an attacker-controlled
|
|
9
|
-
// non-string (e.g. a logoUrl object) can never leak through as a live render
|
|
10
|
-
// token. Exercised only indirectly by the integration path before this.
|
|
5
|
+
// IO-boundary coercion: untrusted `unknown` → BrandingTokens, no `as` cast —
|
|
6
|
+
// every missing/non-string field collapses to "" instead of throwing.
|
|
11
7
|
|
|
12
8
|
describe("coerceBranding", () => {
|
|
13
9
|
const FULL: BrandingTokens = {
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
createInMemoryLoginRateLimiter,
|
|
5
|
+
type PatResolver,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/api";
|
|
7
|
+
import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import {
|
|
11
|
+
setupTestStack,
|
|
12
|
+
type TestStack,
|
|
13
|
+
testTenantId,
|
|
14
|
+
unsafeCreateEntityTable,
|
|
15
|
+
unsafePushTables,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
17
|
+
import { deleteRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
18
|
+
import { Temporal } from "temporal-polyfill";
|
|
19
|
+
import { AuthHandlers } from "../../auth-email-password/constants";
|
|
20
|
+
import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
|
|
21
|
+
import { createConfigFeature } from "../../config";
|
|
22
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
23
|
+
import { configValuesTable } from "../../config/table";
|
|
24
|
+
import { makeSessionHelpers } from "../../sessions/__tests__/test-helpers";
|
|
25
|
+
import { SessionQueries } from "../../sessions/constants";
|
|
26
|
+
import { createSessionsFeature } from "../../sessions/feature";
|
|
27
|
+
import { userSessionEntity } from "../../sessions/schema/user-session";
|
|
28
|
+
import { createTenantFeature } from "../../tenant";
|
|
29
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
30
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
31
|
+
import { createUserFeature } from "../../user/feature";
|
|
32
|
+
import { userEntity } from "../../user/schema/user";
|
|
33
|
+
import { PatHandlers, PatQueries } from "../constants";
|
|
34
|
+
import { createPersonalAccessTokensFeature } from "../feature";
|
|
35
|
+
import { createPatResolver } from "../resolver";
|
|
36
|
+
import { apiTokenEntity, apiTokenTable } from "../schema/api-token";
|
|
37
|
+
import type { PatScopeConfig } from "../scopes";
|
|
38
|
+
|
|
39
|
+
// Full loop, no mocks: mint a PAT via the create handler → use it as a bearer
|
|
40
|
+
// token over real HTTP. The resolver hashes it, resolves live roles, and the
|
|
41
|
+
// API boundary enforces the granted scopes. Proves the security matrix:
|
|
42
|
+
// allowed → 200, out-of-scope → 403 (while a JWT user could call it),
|
|
43
|
+
// revoked/expired/unknown/membership-removed → 401.
|
|
44
|
+
|
|
45
|
+
let stack: TestStack;
|
|
46
|
+
let h: ReturnType<typeof makeSessionHelpers>;
|
|
47
|
+
let patResolver: PatResolver | undefined;
|
|
48
|
+
|
|
49
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
50
|
+
const TENANT: TenantId = testTenantId(1);
|
|
51
|
+
|
|
52
|
+
// One domain "tokens" whose read set is exactly the two PAT queries —
|
|
53
|
+
// deliberately NOT sessions:query:user-session:mine, so that QN is the
|
|
54
|
+
// out-of-scope probe. Granted as "tokens:read".
|
|
55
|
+
const SCOPES: PatScopeConfig = {
|
|
56
|
+
tokens: { label: "Tokens", read: [PatQueries.mine, PatQueries.availableScopes] },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
async function mintToken(
|
|
60
|
+
actor: SessionUser,
|
|
61
|
+
opts?: { scopes?: string[]; expiresInDays?: number },
|
|
62
|
+
): Promise<string> {
|
|
63
|
+
const res = await stack.http.writeOk<{ id: string; token: string }>(
|
|
64
|
+
PatHandlers.create,
|
|
65
|
+
{
|
|
66
|
+
name: "test",
|
|
67
|
+
scopes: opts?.scopes ?? ["tokens:read"],
|
|
68
|
+
...(opts?.expiresInDays ? { expiresInDays: opts.expiresInDays } : {}),
|
|
69
|
+
},
|
|
70
|
+
actor,
|
|
71
|
+
);
|
|
72
|
+
return res.token;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
beforeAll(async () => {
|
|
76
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
77
|
+
const resolver = createConfigResolver({ encryption });
|
|
78
|
+
|
|
79
|
+
stack = await setupTestStack({
|
|
80
|
+
features: [
|
|
81
|
+
createConfigFeature(),
|
|
82
|
+
createUserFeature(),
|
|
83
|
+
createTenantFeature(),
|
|
84
|
+
createAuthEmailPasswordFeature(),
|
|
85
|
+
createSessionsFeature(),
|
|
86
|
+
createPersonalAccessTokensFeature({ scopes: SCOPES }),
|
|
87
|
+
],
|
|
88
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
89
|
+
authConfig: {
|
|
90
|
+
membershipQuery: "tenant:query:memberships",
|
|
91
|
+
loginHandler: AuthHandlers.login,
|
|
92
|
+
patResolver: (raw: string) => {
|
|
93
|
+
if (!patResolver) throw new Error("resolver not set");
|
|
94
|
+
return patResolver(raw);
|
|
95
|
+
},
|
|
96
|
+
// Low per-token cap so the rate-limit test can exhaust it. Other tests do
|
|
97
|
+
// ≤2 requests per (distinct) token, so this ceiling never trips them.
|
|
98
|
+
patRateLimiter: createInMemoryLoginRateLimiter(3, 60_000),
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
patResolver = createPatResolver({ db: stack.db, scopes: SCOPES });
|
|
102
|
+
h = makeSessionHelpers(stack, TENANT);
|
|
103
|
+
|
|
104
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
105
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
106
|
+
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
107
|
+
await unsafeCreateEntityTable(stack.db, apiTokenEntity);
|
|
108
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
afterAll(async () => {
|
|
112
|
+
await stack.cleanup();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
async function actorFor(email: string): Promise<SessionUser> {
|
|
116
|
+
const { userId } = await h.seedUser(email, "pw");
|
|
117
|
+
return { id: userId, tenantId: TENANT, roles: ["User"] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Raw login → JWT (no sid: session callbacks aren't wired here, and the PAT
|
|
121
|
+
// path doesn't need them). Used only to prove a non-PAT bearer skips the gate.
|
|
122
|
+
async function loginToken(email: string): Promise<string> {
|
|
123
|
+
const res = await stack.http.raw("POST", "/api/auth/login", { email, password: "pw" });
|
|
124
|
+
expect(res.status).toBe(200);
|
|
125
|
+
return ((await res.json()) as { token: string }).token;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe("PAT auth", () => {
|
|
129
|
+
test("granted scope → 200", async () => {
|
|
130
|
+
const actor = await actorFor("granted@example.com");
|
|
131
|
+
const token = await mintToken(actor);
|
|
132
|
+
const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
133
|
+
expect(res.status).toBe(200);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("out-of-scope QN → 403 (but a JWT user may call it)", async () => {
|
|
137
|
+
const actor = await actorFor("scope@example.com");
|
|
138
|
+
const token = await mintToken(actor);
|
|
139
|
+
const denied = await h.authedPost("/api/query", token, {
|
|
140
|
+
type: SessionQueries.mine,
|
|
141
|
+
payload: {},
|
|
142
|
+
});
|
|
143
|
+
expect(denied.status).toBe(403);
|
|
144
|
+
|
|
145
|
+
const jwt = await loginToken("scope@example.com");
|
|
146
|
+
const allowed = await h.authedPost("/api/query", jwt, {
|
|
147
|
+
type: SessionQueries.mine,
|
|
148
|
+
payload: {},
|
|
149
|
+
});
|
|
150
|
+
expect(allowed.status).toBe(200);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("unknown token → 401", async () => {
|
|
154
|
+
const res = await h.authedPost("/api/query", "kpat_deadbeef", {
|
|
155
|
+
type: PatQueries.mine,
|
|
156
|
+
payload: {},
|
|
157
|
+
});
|
|
158
|
+
expect(res.status).toBe(401);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("revoked token → 401", async () => {
|
|
162
|
+
const actor = await actorFor("revoked@example.com");
|
|
163
|
+
const token = await mintToken(actor);
|
|
164
|
+
const rows = await stack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
|
|
165
|
+
const id = rows[0]?.id;
|
|
166
|
+
expect(id).toBeDefined();
|
|
167
|
+
await stack.http.writeOk(PatHandlers.revoke, { id }, actor);
|
|
168
|
+
const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
169
|
+
expect(res.status).toBe(401);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("expired token → 401", async () => {
|
|
173
|
+
const actor = await actorFor("expired@example.com");
|
|
174
|
+
const token = await mintToken(actor);
|
|
175
|
+
const past = Temporal.Now.instant().subtract({ hours: 1 });
|
|
176
|
+
const rows =
|
|
177
|
+
(await stack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor)) ?? [];
|
|
178
|
+
await updateMany(stack.db, apiTokenTable, { expiresAt: past }, { id: rows[0]?.id });
|
|
179
|
+
const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
180
|
+
expect(res.status).toBe(401);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("per-token rate limit → 429 once the cap is exceeded", async () => {
|
|
184
|
+
const actor = await actorFor("ratelimit@example.com");
|
|
185
|
+
const token = await mintToken(actor);
|
|
186
|
+
for (let i = 0; i < 3; i++) {
|
|
187
|
+
const ok = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
188
|
+
expect(ok.status).toBe(200);
|
|
189
|
+
}
|
|
190
|
+
const limited = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
191
|
+
expect(limited.status).toBe(429);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("membership removed → 401 (live roles, no snapshot)", async () => {
|
|
195
|
+
const actor = await actorFor("removed@example.com");
|
|
196
|
+
const token = await mintToken(actor);
|
|
197
|
+
const ok = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
198
|
+
expect(ok.status).toBe(200);
|
|
199
|
+
// tenantMembershipsTable is ES-managed (executor-only branded); deleteRows
|
|
200
|
+
// is the test-side escape for out-of-band row removal.
|
|
201
|
+
await deleteRows(stack.db, tenantMembershipsTable, { userId: actor.id });
|
|
202
|
+
const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
203
|
+
expect(res.status).toBe(401);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { expandScopes, type PatScopeConfig, parseGrant } from "../scopes";
|
|
3
|
+
|
|
4
|
+
const CONFIG: PatScopeConfig = {
|
|
5
|
+
credit: { label: "Kredite", read: ["credit:query:*"], write: ["credit:write:*"] },
|
|
6
|
+
miete: { label: "Mieten", read: ["ledger:query:*"] }, // read-only domain
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
describe("parseGrant", () => {
|
|
10
|
+
it("splits domain:level on the last colon", () => {
|
|
11
|
+
expect(parseGrant("credit:write")).toEqual({ domain: "credit", level: "write" });
|
|
12
|
+
});
|
|
13
|
+
it("rejects a bare token", () => {
|
|
14
|
+
expect(parseGrant("credit")).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("expandScopes", () => {
|
|
19
|
+
it("read grants only the read QNs", () => {
|
|
20
|
+
expect(expandScopes(CONFIG, ["credit:read"])).toEqual(["credit:query:*"]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("write grants read + write QNs", () => {
|
|
24
|
+
expect(expandScopes(CONFIG, ["credit:write"]).sort()).toEqual(
|
|
25
|
+
["credit:query:*", "credit:write:*"].sort(),
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("read-only domain ignores a write grant's missing write set", () => {
|
|
30
|
+
expect(expandScopes(CONFIG, ["miete:write"])).toEqual(["ledger:query:*"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("unknown domain contributes nothing (fail-closed)", () => {
|
|
34
|
+
expect(expandScopes(CONFIG, ["ghost:write"])).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("unions across multiple grants without duplicates", () => {
|
|
38
|
+
expect(expandScopes(CONFIG, ["credit:read", "miete:read"]).sort()).toEqual(
|
|
39
|
+
["credit:query:*", "ledger:query:*"].sort(),
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Pure constants — client-marked so the web screen (web/) may import screen-id
|
|
3
|
+
// and QN names without pulling the feature's server runtime barrel.
|
|
4
|
+
export const PAT_FEATURE = "personal-access-tokens";
|
|
5
|
+
|
|
6
|
+
// Snake_case reason strings (Error-Reasons guard: no colons/dashes).
|
|
7
|
+
export const PatErrors = {
|
|
8
|
+
ownershipDenied: "ownership_denied",
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
// Dormant custom-screen id (r.screen) — the app places it via r.nav. The client
|
|
12
|
+
// maps it to the PatTokensScreen component.
|
|
13
|
+
export const PAT_SCREEN_ID = "api-tokens";
|
|
14
|
+
|
|
15
|
+
export const PatHandlers = {
|
|
16
|
+
create: "personal-access-tokens:write:create",
|
|
17
|
+
revoke: "personal-access-tokens:write:revoke",
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export const PatQueries = {
|
|
21
|
+
mine: "personal-access-tokens:query:mine",
|
|
22
|
+
availableScopes: "personal-access-tokens:query:available-scopes",
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
// Only the first chars of a minted token are stored (alongside the hash) so the
|
|
26
|
+
// UI can show "kpat_ab12…" for recognition without ever holding the secret.
|
|
27
|
+
export const PAT_PREFIX_DISPLAY_LENGTH = 12;
|
|
28
|
+
|
|
29
|
+
export type PatRateLimit = { readonly maxRequests: number; readonly windowMs: number };
|
|
30
|
+
|
|
31
|
+
// Default per-token request rate for PAT-authenticated calls. Generous enough
|
|
32
|
+
// for normal automation, low enough to blunt a leaked-token abuse spike.
|
|
33
|
+
export const PAT_DEFAULT_RATE_LIMIT: PatRateLimit = { maxRequests: 120, windowMs: 60_000 };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { buildEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, PAT_SCREEN_ID, type PatRateLimit } from "./constants";
|
|
4
|
+
import { buildAvailableScopesQuery } from "./handlers/available-scopes.query";
|
|
5
|
+
import { createPatWrite } from "./handlers/create.write";
|
|
6
|
+
import { listPatQuery } from "./handlers/list.query";
|
|
7
|
+
import { revokePatWrite } from "./handlers/revoke.write";
|
|
8
|
+
import { apiTokenEntity } from "./schema/api-token";
|
|
9
|
+
import type { PatScopeConfig } from "./scopes";
|
|
10
|
+
|
|
11
|
+
export type PersonalAccessTokensOptions = {
|
|
12
|
+
// The scopes this deployment offers. Each is a named bundle of QN globs a PAT
|
|
13
|
+
// may be granted (a scope can span features). Closed over by available-scopes
|
|
14
|
+
// (UI list) and exported so run-prod-app can build the resolver from the same
|
|
15
|
+
// single source.
|
|
16
|
+
readonly scopes: PatScopeConfig;
|
|
17
|
+
// Per-token request rate limit for PAT-authenticated calls. Defaults to
|
|
18
|
+
// PAT_DEFAULT_RATE_LIMIT (120/60s). run-prod-app builds the limiter from this.
|
|
19
|
+
readonly rateLimit?: PatRateLimit;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type PatFeatureExports = {
|
|
23
|
+
readonly scopes: PatScopeConfig;
|
|
24
|
+
readonly rateLimit: PatRateLimit;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Personal Access Tokens — long-lived, revocable bearer credentials for the
|
|
28
|
+
// HTTP API. Like `sessions`, the hot-path resolver is NOT a handler: it runs on
|
|
29
|
+
// every PAT-authenticated request and does a direct-DB point-read (see
|
|
30
|
+
// createPatResolver / run-prod-app wiring). The dispatcher-side handlers here
|
|
31
|
+
// only mint/list/revoke tokens and expose the scope catalog.
|
|
32
|
+
export function createPersonalAccessTokensFeature(
|
|
33
|
+
options: PersonalAccessTokensOptions,
|
|
34
|
+
): FeatureDefinition {
|
|
35
|
+
const { scopes } = options;
|
|
36
|
+
return defineFeature(PAT_FEATURE, (r) => {
|
|
37
|
+
r.describe(
|
|
38
|
+
"Long-lived, revocable Personal Access Tokens for headless HTTP-API access. Stores SHA-256 token hashes in the `read_api_tokens` direct-write table; the plaintext is returned once at creation. `create`/`revoke`/`mine` manage a user's own tokens and `available-scopes` lists the app-declared scope catalog. Bearer tokens carrying the PAT prefix are resolved before jwt.verify (roles resolved live, granted scopes enforced fail-closed at the API boundary) — the resolver is wired via run-prod-app, not the dispatcher.",
|
|
39
|
+
);
|
|
40
|
+
r.uiHints({ displayLabel: "Personal Access Tokens", category: "identity", recommended: false });
|
|
41
|
+
// Resolver reads memberships + users on every PAT request to build live
|
|
42
|
+
// roles — make both boot-time deps so a mis-wiring fails validateBoot.
|
|
43
|
+
r.requires("user", "tenant");
|
|
44
|
+
// Direct-write store like read_user_sessions: create/revoke write it, the
|
|
45
|
+
// resolver point-reads it. r.entity would make it a rebuildable projection
|
|
46
|
+
// whose replay (no token events) would wipe every live token (#498/#494).
|
|
47
|
+
r.unmanagedTable(buildEntityTableMeta("api-token", apiTokenEntity), {
|
|
48
|
+
reason: "read_side.api_tokens_direct_write",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const handlers = {
|
|
52
|
+
create: r.writeHandler(createPatWrite),
|
|
53
|
+
revoke: r.writeHandler(revokePatWrite),
|
|
54
|
+
};
|
|
55
|
+
const queries = {
|
|
56
|
+
mine: r.queryHandler(listPatQuery),
|
|
57
|
+
availableScopes: r.queryHandler(buildAvailableScopesQuery(scopes)),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Dormant custom-screen — the client maps PAT_SCREEN_ID to PatTokensScreen;
|
|
61
|
+
// the app places it via r.nav in its logged-in settings area.
|
|
62
|
+
r.screen({
|
|
63
|
+
id: PAT_SCREEN_ID,
|
|
64
|
+
type: "custom",
|
|
65
|
+
renderer: { react: { __component: "PatTokensScreen" } },
|
|
66
|
+
access: { openToAll: true },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// scopes + rateLimit flow into feature.exports so run-prod-app builds the
|
|
70
|
+
// resolver + limiter from the same declaration — single source of truth.
|
|
71
|
+
return {
|
|
72
|
+
handlers,
|
|
73
|
+
queries,
|
|
74
|
+
scopes,
|
|
75
|
+
rateLimit: options.rateLimit ?? PAT_DEFAULT_RATE_LIMIT,
|
|
76
|
+
} satisfies { handlers: unknown; queries: unknown } & PatFeatureExports;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { PatScopeConfig } from "../scopes";
|
|
4
|
+
|
|
5
|
+
// Returns the app-declared scope domains ({name, label, canWrite}) so the mint
|
|
6
|
+
// UI can render a per-domain level picker (no access / read / read & write).
|
|
7
|
+
// canWrite is false for read-only domains → the UI hides the write option.
|
|
8
|
+
// Static per deployment, not per user.
|
|
9
|
+
export function buildAvailableScopesQuery(scopes: PatScopeConfig) {
|
|
10
|
+
return defineQueryHandler({
|
|
11
|
+
name: "available-scopes",
|
|
12
|
+
schema: z.object({}),
|
|
13
|
+
access: { openToAll: true },
|
|
14
|
+
handler: async () =>
|
|
15
|
+
Object.entries(scopes).map(([name, def]) => ({
|
|
16
|
+
name,
|
|
17
|
+
label: def.label,
|
|
18
|
+
canWrite: (def.write?.length ?? 0) > 0,
|
|
19
|
+
})),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
4
|
+
import { Temporal } from "temporal-polyfill";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { mintPatToken } from "../hash";
|
|
7
|
+
import { apiTokenTable } from "../schema/api-token";
|
|
8
|
+
|
|
9
|
+
// Mint a PAT for the calling user in their active tenant. The plaintext token
|
|
10
|
+
// is returned ONCE (data.token) and never again — only the hash is stored.
|
|
11
|
+
// `scopes` are granted scope names; unknown names simply grant nothing at
|
|
12
|
+
// resolve time (fail-closed), so no cross-check against the app config here.
|
|
13
|
+
export const createPatWrite = defineWriteHandler({
|
|
14
|
+
name: "create",
|
|
15
|
+
schema: z.object({
|
|
16
|
+
name: z.string().min(1).max(120),
|
|
17
|
+
scopes: z.array(z.string().min(1)).min(1),
|
|
18
|
+
expiresInDays: z.number().int().positive().max(3650).optional(),
|
|
19
|
+
}),
|
|
20
|
+
access: { openToAll: true },
|
|
21
|
+
handler: async (event, ctx) => {
|
|
22
|
+
const { raw, hash, prefix } = mintPatToken();
|
|
23
|
+
const now = Temporal.Now.instant();
|
|
24
|
+
const id = generateId();
|
|
25
|
+
await insertOne(ctx.db, apiTokenTable, {
|
|
26
|
+
id,
|
|
27
|
+
userId: event.user.id,
|
|
28
|
+
tenantId: event.user.tenantId,
|
|
29
|
+
name: event.payload.name,
|
|
30
|
+
tokenHash: hash,
|
|
31
|
+
prefix,
|
|
32
|
+
scopes: JSON.stringify(event.payload.scopes),
|
|
33
|
+
createdAt: now,
|
|
34
|
+
expiresAt: event.payload.expiresInDays
|
|
35
|
+
? now.add({ hours: 24 * event.payload.expiresInDays })
|
|
36
|
+
: null,
|
|
37
|
+
revokedAt: null,
|
|
38
|
+
});
|
|
39
|
+
return { isSuccess: true, data: { id, token: raw, prefix } };
|
|
40
|
+
},
|
|
41
|
+
});
|