create-ailk 0.19.1 → 0.20.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/README.md +64 -7
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/module-architecture.js +55 -2
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +32 -2
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.js +67 -2
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/CLAUDE.md +14 -10
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
- package/templates/apps/api/src/middleware/auth.ts +8 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
- package/templates/apps/api/src/server.ts +14 -2
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/messages/en.json +67 -4
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +39 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +38 -12
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -39,6 +39,12 @@
|
|
|
39
39
|
* `outcome: "foreign_site"` and answers 200. An unstamped session is the
|
|
40
40
|
* hub's own (roadmap D4) and is handled as before. The hub stamps its own
|
|
41
41
|
* sessions through `siteStamp()`. See README.md § Site filter (#7868).
|
|
42
|
+
* Since #8024 one gate before the switch does the same for every
|
|
43
|
+
* `customer.*`, `customer.subscription.*` and `invoice.*` event, reading the
|
|
44
|
+
* stamp from the Customer's or Subscription's own metadata — for an invoice,
|
|
45
|
+
* its own metadata and then the subscription snapshot under
|
|
46
|
+
* `parent.subscription_details` or (pre-`basil`) `subscription_details` —
|
|
47
|
+
* and only when `data.object.object` is the family's own type.
|
|
42
48
|
*
|
|
43
49
|
* Billing upsert (customer.created / checkout.session.completed):
|
|
44
50
|
* Billing-entity routing (org-scoped billing, #3601, spec D2): a checkout
|
|
@@ -70,6 +76,20 @@
|
|
|
70
76
|
* `"no_payment_required"` (a 100%-off promo code, #4862) — anything else
|
|
71
77
|
* logs `outcome: "unpaid"` and leaves the row `created`.
|
|
72
78
|
*
|
|
79
|
+
* Proposal paid stamp (checkout.session.completed, #8516 D13):
|
|
80
|
+
* A session carrying the server-written `metadata.ailk_proposal` and
|
|
81
|
+
* `ailk_workspace` (the client's proposal Checkout,
|
|
82
|
+
* `services/proposal-payments.ts`) stamps that proposal's `paid_at` and
|
|
83
|
+
* `paid_session_id` once, through the base-tree boundary
|
|
84
|
+
* `lib/proposal-paid-store.ts` (this file names no `piece` delegate), and
|
|
85
|
+
* writes one `people.proposal.paid` audit row as the Stripe system actor
|
|
86
|
+
* keyed by the event id: `succeeded` when stamped, `failed`
|
|
87
|
+
* (`already_paid`) when a different session already paid it, none on a
|
|
88
|
+
* same-session replay, a `warn` when no such proposal exists, and `failed`
|
|
89
|
+
* (`not_paid`) with no stamp for a session that is not paid. A session
|
|
90
|
+
* without both keys makes no query. Runs BESIDE the other stamps; never
|
|
91
|
+
* throws; logs an 8-character prefix and the outcome only.
|
|
92
|
+
*
|
|
73
93
|
* Checkout Person (checkout.session.completed, #852 D6):
|
|
74
94
|
* Chained after that stamp, `recordFlowCheckoutPerson` finds or creates the
|
|
75
95
|
* Person of the checkout row THIS session completed (matched on its own
|
|
@@ -87,10 +107,12 @@
|
|
|
87
107
|
*/
|
|
88
108
|
import { track } from "@working-theory/analytics/server";
|
|
89
109
|
import type { Prisma } from "@working-theory/database";
|
|
110
|
+
import { isId } from "@working-theory/ids";
|
|
90
111
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
91
112
|
|
|
92
113
|
import { config } from "../../config/index.js";
|
|
93
114
|
import { prisma } from "../../lib/prisma.js";
|
|
115
|
+
import { stampProposalPaid as writeProposalPaid } from "../../lib/proposal-paid-store.js";
|
|
94
116
|
import {
|
|
95
117
|
resolveCommerceClaim,
|
|
96
118
|
commerceDisabledResponse,
|
|
@@ -110,6 +132,7 @@ import {
|
|
|
110
132
|
verifyWebhookSignature,
|
|
111
133
|
} from "../../lib/stripe.js";
|
|
112
134
|
import { hasSeen, markSeen } from "../../lib/webhook-idempotency.js";
|
|
135
|
+
import { recordWorkspaceAuditAction } from "../../services/audit-log.js";
|
|
113
136
|
import { isUniqueViolation } from "../../services/default-tenant.js";
|
|
114
137
|
import {
|
|
115
138
|
DUNNING_MAX_SUBSCRIPTIONS,
|
|
@@ -309,6 +332,20 @@ export const stripeWebhookHandler = async (
|
|
|
309
332
|
}
|
|
310
333
|
markSeen(event.id);
|
|
311
334
|
|
|
335
|
+
// #8024 — the billing families' site filter, after `markSeen` as #7868's
|
|
336
|
+
// in-arm check is, so a foreign redelivery is a duplicate. One gate rather
|
|
337
|
+
// than a check per arm: the `default` arm's types are filtered too.
|
|
338
|
+
const foreignStamp = siteStampSourcesOf(event).find(isForeignSiteStamp);
|
|
339
|
+
if (foreignStamp) {
|
|
340
|
+
logForeignSiteSkip(request, {
|
|
341
|
+
stamp: foreignStamp[SITE_STAMP_KEY],
|
|
342
|
+
eventType: event.type,
|
|
343
|
+
eventIdPrefix,
|
|
344
|
+
});
|
|
345
|
+
await reply.code(200).send({ received: true });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
312
349
|
switch (event.type) {
|
|
313
350
|
case "customer.created": {
|
|
314
351
|
const customer = event.data.object as {
|
|
@@ -400,6 +437,7 @@ export const stripeWebhookHandler = async (
|
|
|
400
437
|
}),
|
|
401
438
|
),
|
|
402
439
|
stampRequestPaid(request, { session, eventIdPrefix }),
|
|
440
|
+
stampProposalPaid(request, { session, eventId: event.id, eventIdPrefix }),
|
|
403
441
|
]);
|
|
404
442
|
break;
|
|
405
443
|
}
|
|
@@ -497,11 +535,11 @@ type CheckoutSessionObject = {
|
|
|
497
535
|
};
|
|
498
536
|
|
|
499
537
|
/**
|
|
500
|
-
* The one log line for
|
|
501
|
-
* foreign stamp (first 64 characters) and this site's own alias,
|
|
502
|
-
* event correctly skipped can be told from a hub event whose alias
|
|
503
|
-
* site labels only: it takes the stamp, never the
|
|
504
|
-
* or amount can reach it.
|
|
538
|
+
* The one log line for an event another site stamped (#7868 D3, #8024 D3).
|
|
539
|
+
* Carries the foreign stamp (first 64 characters) and this site's own alias,
|
|
540
|
+
* so a spoke event correctly skipped can be told from a hub event whose alias
|
|
541
|
+
* drifted — site labels only: it takes the stamp, never the object, so no
|
|
542
|
+
* buyer field, customer id or amount can reach it.
|
|
505
543
|
*/
|
|
506
544
|
function logForeignSiteSkip(
|
|
507
545
|
request: FastifyRequest,
|
|
@@ -523,10 +561,61 @@ function logForeignSiteSkip(
|
|
|
523
561
|
stamp: stamp?.slice(0, 64),
|
|
524
562
|
ownSite: currentSiteId(),
|
|
525
563
|
},
|
|
526
|
-
"
|
|
564
|
+
"stripe event stamped for another site — skipped",
|
|
527
565
|
);
|
|
528
566
|
}
|
|
529
567
|
|
|
568
|
+
/** The object type whose metadata carries the site for each billing family (#8024 D2). */
|
|
569
|
+
function stampedObjectTypeOf(eventType: string): "subscription" | "customer" | "invoice" | null {
|
|
570
|
+
if (eventType.startsWith("customer.subscription.")) return "subscription";
|
|
571
|
+
if (eventType.startsWith("customer.")) return "customer";
|
|
572
|
+
if (eventType.startsWith("invoice.")) return "invoice";
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** `value` when it is a non-null object, else `null` — every step of a stamp read is guarded (NF1). */
|
|
577
|
+
function objectOrNull(value: unknown): Record<string, unknown> | null {
|
|
578
|
+
return typeof value === "object" && value !== null
|
|
579
|
+
? (value as Record<string, unknown>)
|
|
580
|
+
: null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* The metadata objects that may carry an event's site stamp (#8024 D2), in the
|
|
585
|
+
* order they are checked: a Subscription's or a Customer's own metadata; an
|
|
586
|
+
* invoice's own metadata, then the subscription snapshot at the pinned API
|
|
587
|
+
* version (`parent.subscription_details.metadata`), then at a pre-`basil` one
|
|
588
|
+
* (`subscription_details.metadata`). Empty for every other event type, and
|
|
589
|
+
* when `data.object.object` is not the family's own type — a
|
|
590
|
+
* `customer.source.*` card, say. Reads the payload alone and never throws on
|
|
591
|
+
* any shape: an absent, `null` or non-object step yields no source, and
|
|
592
|
+
* `isForeignSiteStamp` treats a non-string stamp as unstamped.
|
|
593
|
+
*/
|
|
594
|
+
function siteStampSourcesOf(event: {
|
|
595
|
+
type: string;
|
|
596
|
+
data?: unknown;
|
|
597
|
+
}): Record<string, string>[] {
|
|
598
|
+
const objectType = stampedObjectTypeOf(event.type);
|
|
599
|
+
const object = objectOrNull(objectOrNull(event.data)?.["object"]);
|
|
600
|
+
if (objectType === null || object?.["object"] !== objectType) return [];
|
|
601
|
+
|
|
602
|
+
const candidates =
|
|
603
|
+
objectType === "invoice"
|
|
604
|
+
? [
|
|
605
|
+
object["metadata"],
|
|
606
|
+
objectOrNull(objectOrNull(object["parent"])?.["subscription_details"])?.[
|
|
607
|
+
"metadata"
|
|
608
|
+
],
|
|
609
|
+
objectOrNull(object["subscription_details"])?.["metadata"],
|
|
610
|
+
]
|
|
611
|
+
: [object["metadata"]];
|
|
612
|
+
// The cast only narrows the value type for `isForeignSiteStamp`, which
|
|
613
|
+
// reads the stamp as `unknown` and `typeof`-checks it.
|
|
614
|
+
return candidates
|
|
615
|
+
.map(objectOrNull)
|
|
616
|
+
.filter((source) => source !== null) as Record<string, string>[];
|
|
617
|
+
}
|
|
618
|
+
|
|
530
619
|
/**
|
|
531
620
|
* Minimal projected shape of a Stripe Invoice object for the dunning arms
|
|
532
621
|
* (#6034 FR-1). `subscription` and `customer` are EXPANDABLE Stripe fields —
|
|
@@ -803,6 +892,95 @@ async function stampRequestPaid(
|
|
|
803
892
|
}
|
|
804
893
|
}
|
|
805
894
|
|
|
895
|
+
/**
|
|
896
|
+
* #8516 D13 — stamp a client's proposal paid from its own Checkout Session,
|
|
897
|
+
* and audit the outcome as Stripe. The two ids come only from the session's
|
|
898
|
+
* metadata, which this server wrote when it opened the session — a payer can
|
|
899
|
+
* never set it — and the stamp is scoped by both. Never throws.
|
|
900
|
+
*/
|
|
901
|
+
async function stampProposalPaid(
|
|
902
|
+
request: FastifyRequest,
|
|
903
|
+
{
|
|
904
|
+
session,
|
|
905
|
+
eventId,
|
|
906
|
+
eventIdPrefix,
|
|
907
|
+
}: { session: CheckoutSessionObject; eventId: string; eventIdPrefix: string },
|
|
908
|
+
): Promise<void> {
|
|
909
|
+
const pieceId = session.metadata?.["ailk_proposal"];
|
|
910
|
+
const workspaceId = session.metadata?.["ailk_workspace"];
|
|
911
|
+
const sessionId = session.id;
|
|
912
|
+
if (
|
|
913
|
+
typeof pieceId !== "string" ||
|
|
914
|
+
typeof workspaceId !== "string" ||
|
|
915
|
+
typeof sessionId !== "string" ||
|
|
916
|
+
!isId("piece", pieceId) ||
|
|
917
|
+
!isId("workspace", workspaceId)
|
|
918
|
+
) {
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const ref = pieceId.substring(6, 14);
|
|
922
|
+
|
|
923
|
+
const audit = async (outcome: { ok: true } | { ok: false; reason: string }) => {
|
|
924
|
+
const result = await recordWorkspaceAuditAction({
|
|
925
|
+
workspaceId,
|
|
926
|
+
actor: { type: "system", systemId: "stripe" },
|
|
927
|
+
domain: "people",
|
|
928
|
+
tool: "proposal.paid",
|
|
929
|
+
outcome: outcome.ok ? "succeeded" : "failed",
|
|
930
|
+
...(!outcome.ok && { failureReason: outcome.reason }),
|
|
931
|
+
idempotencyKey: eventId,
|
|
932
|
+
targetType: "piece",
|
|
933
|
+
targetId: pieceId,
|
|
934
|
+
});
|
|
935
|
+
if (!result.ok) {
|
|
936
|
+
request.log.error(
|
|
937
|
+
{ eventIdPrefix, ref, error: result.error.kind },
|
|
938
|
+
"proposal paid: audit write failed",
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
if (
|
|
944
|
+
session.payment_status !== "paid" &&
|
|
945
|
+
session.payment_status !== "no_payment_required"
|
|
946
|
+
) {
|
|
947
|
+
request.log.warn(
|
|
948
|
+
{ eventIdPrefix, ref, outcome: "not_paid" },
|
|
949
|
+
"proposal paid: completed session is not paid — not stamping",
|
|
950
|
+
);
|
|
951
|
+
await audit({ ok: false, reason: "not_paid" });
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
try {
|
|
956
|
+
const outcome = await writeProposalPaid(workspaceId, pieceId, sessionId, new Date());
|
|
957
|
+
if (outcome === "stamped") {
|
|
958
|
+
request.log.info({ eventIdPrefix, ref, outcome }, "proposal stamped paid");
|
|
959
|
+
await audit({ ok: true });
|
|
960
|
+
} else if (outcome === "already_paid") {
|
|
961
|
+
request.log.warn(
|
|
962
|
+
{ eventIdPrefix, ref, outcome },
|
|
963
|
+
"proposal paid: already paid by another session — a second charge",
|
|
964
|
+
);
|
|
965
|
+
await audit({ ok: false, reason: "already_paid" });
|
|
966
|
+
} else if (outcome === "not_found") {
|
|
967
|
+
request.log.warn({ eventIdPrefix, ref, outcome }, "proposal paid: no such proposal");
|
|
968
|
+
} else {
|
|
969
|
+
request.log.info({ eventIdPrefix, ref, outcome }, "proposal paid: replay");
|
|
970
|
+
}
|
|
971
|
+
} catch (err) {
|
|
972
|
+
request.log.error(
|
|
973
|
+
{
|
|
974
|
+
eventIdPrefix,
|
|
975
|
+
ref,
|
|
976
|
+
outcome: "stamp_failed",
|
|
977
|
+
errName: err instanceof Error ? err.name : "UnknownError",
|
|
978
|
+
},
|
|
979
|
+
"proposal paid stamp failed — continuing",
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
806
984
|
/**
|
|
807
985
|
* The hosted-invoice twin of `stampRequestPaid` (#5970 D4/D8).
|
|
808
986
|
*
|
|
@@ -235,7 +235,8 @@ const WORKSPACES_SUBSCRIBERS = "./routes/workspaces/subscribers.js";
|
|
|
235
235
|
// #852 D8 — the People reads (the workspace's clients), the SAME scope.
|
|
236
236
|
const WORKSPACES_PEOPLE = "./routes/workspaces/people.js";
|
|
237
237
|
// #7877 D9 — a client's pieces, plan and outbound messages, and the
|
|
238
|
-
// workspace's piece stages: the
|
|
238
|
+
// workspace's piece stages: the writes owners, admins and (#8514) members
|
|
239
|
+
// make, the SAME scope.
|
|
239
240
|
const WORKSPACES_PIECES = "./routes/workspaces/pieces.js";
|
|
240
241
|
const WORKSPACES_PLAN = "./routes/workspaces/plan.js";
|
|
241
242
|
const WORKSPACES_MESSAGES_OUT = "./routes/workspaces/messages-out.js";
|
|
@@ -267,6 +268,9 @@ const MEMBER_BOOKINGS = "./routes/member/bookings.js";
|
|
|
267
268
|
// other member routes. D8 — the Admin owner's consents to Working Theory,
|
|
268
269
|
// under multi-tenant alone (the module that contributes the Admin app).
|
|
269
270
|
const MEMBER_CONSENTS = "./routes/member/consents.js";
|
|
271
|
+
// #8515 D13 — the client's onboarding read and write, under the tenancy
|
|
272
|
+
// predicate beside the request-quote pair.
|
|
273
|
+
const MEMBER_ONBOARDING = "./routes/member/onboarding.js";
|
|
270
274
|
const CONSENT_PLATFORM = "./routes/consent/platform.js";
|
|
271
275
|
// #3591 PR-B — the agent seat's management routes and an agent's own
|
|
272
276
|
// context, siblings of WORKSPACES_MEMBERS in the same workspace/multi-tenant
|
|
@@ -348,7 +352,8 @@ const SLICE_PLUGINS = {
|
|
|
348
352
|
specifier: WORKSPACES_PEOPLE,
|
|
349
353
|
load: () => import(WORKSPACES_PEOPLE),
|
|
350
354
|
},
|
|
351
|
-
// #7877 D9 — the member area's operator routes,
|
|
355
|
+
// #7877 D9 — the member area's operator routes (owners, admins and, since
|
|
356
|
+
// #8514, members), the SAME scope.
|
|
352
357
|
workspacePieceRoutes: {
|
|
353
358
|
specifier: WORKSPACES_PIECES,
|
|
354
359
|
load: () => import(WORKSPACES_PIECES),
|
|
@@ -410,6 +415,11 @@ const SLICE_PLUGINS = {
|
|
|
410
415
|
specifier: MEMBER_REQUESTS,
|
|
411
416
|
load: () => import(MEMBER_REQUESTS),
|
|
412
417
|
},
|
|
418
|
+
// #8515 D13 — the onboarding read and write.
|
|
419
|
+
memberOnboardingRoutes: {
|
|
420
|
+
specifier: MEMBER_ONBOARDING,
|
|
421
|
+
load: () => import(MEMBER_ONBOARDING),
|
|
422
|
+
},
|
|
413
423
|
memberArtifactReviewRoutes: {
|
|
414
424
|
specifier: MEMBER_ARTIFACT_REVIEW,
|
|
415
425
|
load: () => import(MEMBER_ARTIFACT_REVIEW),
|
|
@@ -1063,6 +1073,8 @@ export async function build() {
|
|
|
1063
1073
|
await authScope.register(await sliceRoutePlugin("memberBillingRoutes"));
|
|
1064
1074
|
// #8170 D11 — the member writes, under the same predicate.
|
|
1065
1075
|
await authScope.register(await sliceRoutePlugin("memberRequestRoutes"));
|
|
1076
|
+
// #8515 D13 — the onboarding pair, under the same predicate.
|
|
1077
|
+
await authScope.register(await sliceRoutePlugin("memberOnboardingRoutes"));
|
|
1066
1078
|
await authScope.register(await sliceRoutePlugin("memberArtifactReviewRoutes"));
|
|
1067
1079
|
await authScope.register(await sliceRoutePlugin("memberProfileRoutes"));
|
|
1068
1080
|
await authScope.register(await sliceRoutePlugin("memberDeletionRequestRoutes"));
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
freshAuditIdempotencyKey,
|
|
52
52
|
recordAgentWrite,
|
|
53
53
|
recordAuditAction,
|
|
54
|
+
recordWorkspaceAuditAction,
|
|
54
55
|
redactPersonalData,
|
|
55
56
|
type AuditActor,
|
|
56
57
|
type AuditRowDigestInput,
|
|
@@ -935,3 +936,93 @@ describe("recordAgentWrite given the request (#3591 PR-B)", () => {
|
|
|
935
936
|
expect(mockLoggerError.mock.calls[0]![1]).toBe("[audit-log] agent write audit refused");
|
|
936
937
|
});
|
|
937
938
|
});
|
|
939
|
+
|
|
940
|
+
// ─── #8516 D14 — recordWorkspaceAuditAction, a row without a tenant ────────────
|
|
941
|
+
|
|
942
|
+
describe("#8516 AC-7 — recordWorkspaceAuditAction", () => {
|
|
943
|
+
const PIECE_ID = "piece_0190f3a2-7c1d-7e4a-9b2c-000000000004";
|
|
944
|
+
const ROW = {
|
|
945
|
+
domain: "people",
|
|
946
|
+
tool: "proposal.client_review",
|
|
947
|
+
outcome: "succeeded" as const,
|
|
948
|
+
idempotencyKey: "req_proposal_review",
|
|
949
|
+
targetType: "piece",
|
|
950
|
+
targetId: PIECE_ID,
|
|
951
|
+
summary: '{"by":"client","review":"approved","priced":true}',
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
it("writes the row recordAuditAction would for the same workspace and user actor", async () => {
|
|
955
|
+
const tenantWrite = stubDb();
|
|
956
|
+
const tenantResult = await recordAuditAction(
|
|
957
|
+
{ ...ROW, tenant: TENANT, actor: userActor(USER_ID) },
|
|
958
|
+
tenantWrite.db,
|
|
959
|
+
);
|
|
960
|
+
const workspaceWrite = stubDb();
|
|
961
|
+
const workspaceResult = await recordWorkspaceAuditAction(
|
|
962
|
+
{ ...ROW, workspaceId: WORKSPACE_ID, actor: { type: "user", userId: USER_ID as Id<"user"> } },
|
|
963
|
+
workspaceWrite.db,
|
|
964
|
+
);
|
|
965
|
+
|
|
966
|
+
expect(workspaceWrite.create).toHaveBeenCalledTimes(1);
|
|
967
|
+
expect(firstWrittenRow(workspaceWrite.create)).toEqual(firstWrittenRow(tenantWrite.create));
|
|
968
|
+
expect(workspaceResult).toEqual(tenantResult);
|
|
969
|
+
expect(workspaceResult).toMatchObject({ ok: true, outcome: "recorded" });
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
it("the system actor stores actor_type system and actor_id stripe", async () => {
|
|
973
|
+
const { db, create } = stubDb();
|
|
974
|
+
const result = await recordWorkspaceAuditAction(
|
|
975
|
+
{
|
|
976
|
+
workspaceId: WORKSPACE_ID,
|
|
977
|
+
actor: { type: "system", systemId: "stripe" },
|
|
978
|
+
domain: "people",
|
|
979
|
+
tool: "proposal.paid",
|
|
980
|
+
outcome: "failed",
|
|
981
|
+
failureReason: "already_paid",
|
|
982
|
+
idempotencyKey: "evt_test_proposal",
|
|
983
|
+
targetType: "piece",
|
|
984
|
+
targetId: PIECE_ID,
|
|
985
|
+
},
|
|
986
|
+
db,
|
|
987
|
+
);
|
|
988
|
+
|
|
989
|
+
expect(result).toMatchObject({ ok: true, outcome: "recorded" });
|
|
990
|
+
expect(create).toHaveBeenCalledTimes(1);
|
|
991
|
+
const row = firstWrittenRow(create);
|
|
992
|
+
expect(row).toMatchObject({
|
|
993
|
+
workspaceId: WORKSPACE_ID,
|
|
994
|
+
actorType: "system",
|
|
995
|
+
actorId: "stripe",
|
|
996
|
+
action: "people.proposal.paid",
|
|
997
|
+
targetType: "piece",
|
|
998
|
+
targetId: PIECE_ID,
|
|
999
|
+
});
|
|
1000
|
+
expect(row.metadata).toEqual({
|
|
1001
|
+
v: 2,
|
|
1002
|
+
outcome: "failed",
|
|
1003
|
+
tool: "proposal.paid",
|
|
1004
|
+
idempotencyKey: "evt_test_proposal",
|
|
1005
|
+
failureReason: "already_paid",
|
|
1006
|
+
});
|
|
1007
|
+
expect(row.metadata).not.toHaveProperty("onBehalfOf");
|
|
1008
|
+
expect(zAuditLogId.safeParse(row.id).success).toBe(true);
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
it("an invalid workspaceId is invalid_input and writes nothing", async () => {
|
|
1012
|
+
const { db, create } = stubDb();
|
|
1013
|
+
const result = await recordWorkspaceAuditAction(
|
|
1014
|
+
{ ...ROW, workspaceId: "not-a-workspace", actor: { type: "system", systemId: "stripe" } },
|
|
1015
|
+
db,
|
|
1016
|
+
);
|
|
1017
|
+
|
|
1018
|
+
expect(result).toEqual({
|
|
1019
|
+
ok: false,
|
|
1020
|
+
error: {
|
|
1021
|
+
kind: "invalid_input",
|
|
1022
|
+
message: "workspaceId is not a valid workspace id",
|
|
1023
|
+
persisted: false,
|
|
1024
|
+
},
|
|
1025
|
+
});
|
|
1026
|
+
expect(create).not.toHaveBeenCalled();
|
|
1027
|
+
});
|
|
1028
|
+
});
|
|
@@ -99,8 +99,8 @@ import { isUniqueViolation, type TenantContext } from "./default-tenant.js";
|
|
|
99
99
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
100
100
|
|
|
101
101
|
/** The full actor-kind vocabulary the model comments (not a DB CHECK). This
|
|
102
|
-
* file writes `"user"` and `"agent"` (#3591 D8)
|
|
103
|
-
*
|
|
102
|
+
* file writes `"user"` and `"agent"` (#3591 D8), and `"system"` for Stripe's
|
|
103
|
+
* payment outcome (#8516 D14, `recordWorkspaceAuditAction`). */
|
|
104
104
|
export type AuditActorType = "user" | "agent" | "system";
|
|
105
105
|
|
|
106
106
|
/**
|
|
@@ -210,6 +210,12 @@ export interface RecordAuditActionInput {
|
|
|
210
210
|
targetId?: string;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
/** Every member of a row's input but who and where — shared by both writers. */
|
|
214
|
+
type AuditRowInput = Omit<RecordAuditActionInput, "tenant" | "actor">;
|
|
215
|
+
|
|
216
|
+
/** Every actor a row can name: a tenant caller's, or a tenant-less one's (D14). */
|
|
217
|
+
type WriterActor = AuditActor | { type: "system"; systemId: "stripe" };
|
|
218
|
+
|
|
213
219
|
export type RecordAuditActionResult =
|
|
214
220
|
| { ok: true; outcome: "recorded" | "duplicate"; id: Id<"auditLog"> }
|
|
215
221
|
| {
|
|
@@ -487,7 +493,7 @@ function classifyError(error: unknown): { code: string; name: string } {
|
|
|
487
493
|
interface ValidatedFields {
|
|
488
494
|
workspaceId: string;
|
|
489
495
|
actorId: string;
|
|
490
|
-
actorType:
|
|
496
|
+
actorType: AuditActorType;
|
|
491
497
|
onBehalfOf: string | undefined;
|
|
492
498
|
tool: string;
|
|
493
499
|
targetType: string | undefined;
|
|
@@ -547,15 +553,24 @@ function actorIdRefusal(id: string, field: string): string | undefined {
|
|
|
547
553
|
* (never a silent rewrite of an identifier field) rather than as a
|
|
548
554
|
* redaction, unlike `summary` / `failureReason`.
|
|
549
555
|
*/
|
|
550
|
-
function validateInput(
|
|
551
|
-
|
|
556
|
+
function validateInput(
|
|
557
|
+
workspaceId: string,
|
|
558
|
+
workspaceField: string,
|
|
559
|
+
actor: WriterActor,
|
|
560
|
+
input: AuditRowInput,
|
|
561
|
+
): ValidationResult {
|
|
562
|
+
if (!zWorkspaceId.safeParse(workspaceId).success) {
|
|
552
563
|
return {
|
|
553
564
|
ok: false,
|
|
554
|
-
message:
|
|
565
|
+
message: `${workspaceField} is not a valid workspace id`,
|
|
555
566
|
};
|
|
556
567
|
}
|
|
557
|
-
const
|
|
558
|
-
|
|
568
|
+
const actorId =
|
|
569
|
+
actor.type === "agent"
|
|
570
|
+
? actor.agentId
|
|
571
|
+
: actor.type === "system"
|
|
572
|
+
? actor.systemId
|
|
573
|
+
: actor.userId;
|
|
559
574
|
const actorRefusal = actorIdRefusal(actorId, "actorId");
|
|
560
575
|
if (actorRefusal !== undefined) {
|
|
561
576
|
return { ok: false, message: actorRefusal };
|
|
@@ -628,7 +643,7 @@ function validateInput(input: RecordAuditActionInput): ValidationResult {
|
|
|
628
643
|
return {
|
|
629
644
|
ok: true,
|
|
630
645
|
value: {
|
|
631
|
-
workspaceId
|
|
646
|
+
workspaceId,
|
|
632
647
|
actorId,
|
|
633
648
|
actorType: actor.type,
|
|
634
649
|
onBehalfOf,
|
|
@@ -674,7 +689,54 @@ export async function recordAuditAction(
|
|
|
674
689
|
input: RecordAuditActionInput,
|
|
675
690
|
db: AuditWriteClient = prisma,
|
|
676
691
|
): Promise<RecordAuditActionResult> {
|
|
677
|
-
const
|
|
692
|
+
const { tenant, actor, ...row } = input;
|
|
693
|
+
return writeAuditRow(tenant.workspaceId, "tenant.workspaceId", actor, row, db);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Who acted on a tenant-less row (#8516 D14): a client — a signed-in person
|
|
698
|
+
* with no membership — or Stripe itself, reporting a payment outcome.
|
|
699
|
+
*/
|
|
700
|
+
export type WorkspaceAuditActor =
|
|
701
|
+
| { type: "user"; userId: Id<"user"> }
|
|
702
|
+
| { type: "system"; systemId: "stripe" };
|
|
703
|
+
|
|
704
|
+
/** `recordWorkspaceAuditAction`'s input: a workspace id in place of a tenant. */
|
|
705
|
+
export type RecordWorkspaceAuditActionInput = AuditRowInput & {
|
|
706
|
+
workspaceId: string;
|
|
707
|
+
actor: WorkspaceAuditActor;
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* One `audit_logs` row for a caller that holds no `TenantContext` (#8516
|
|
712
|
+
* D14): a client acting in the member area, or a Stripe webhook. Writes the
|
|
713
|
+
* row `recordAuditAction` would for the same workspace and user actor; the
|
|
714
|
+
* system actor stores `actor_type` `system` and `actor_id` `stripe`. Never
|
|
715
|
+
* throws; never blocks the caller's action.
|
|
716
|
+
*
|
|
717
|
+
* CALLER OBLIGATION — `workspaceId` MUST come from `resolveMember`'s
|
|
718
|
+
* resolved workspace, or from a Stripe-signed event's server-written
|
|
719
|
+
* metadata matched to a stored row — NEVER from request input. A client
|
|
720
|
+
* actor's row says so in its `tool` and summary (`by: "client"`), so it is
|
|
721
|
+
* never read as a staff member's action.
|
|
722
|
+
*/
|
|
723
|
+
export async function recordWorkspaceAuditAction(
|
|
724
|
+
input: RecordWorkspaceAuditActionInput,
|
|
725
|
+
db: AuditWriteClient = prisma,
|
|
726
|
+
): Promise<RecordAuditActionResult> {
|
|
727
|
+
const { workspaceId, actor, ...row } = input;
|
|
728
|
+
return writeAuditRow(workspaceId, "workspaceId", actor, row, db);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** The body of both writers: one validated, digested, idempotent insert. */
|
|
732
|
+
async function writeAuditRow(
|
|
733
|
+
workspaceIdInput: string,
|
|
734
|
+
workspaceField: string,
|
|
735
|
+
actor: WriterActor,
|
|
736
|
+
input: AuditRowInput,
|
|
737
|
+
db: AuditWriteClient,
|
|
738
|
+
): Promise<RecordAuditActionResult> {
|
|
739
|
+
const validated = validateInput(workspaceIdInput, workspaceField, actor, input);
|
|
678
740
|
if (!validated.ok) {
|
|
679
741
|
return {
|
|
680
742
|
ok: false,
|
|
@@ -74,6 +74,13 @@ export interface TenantContext {
|
|
|
74
74
|
* `@working-theory/validation`'s `allows`.
|
|
75
75
|
*/
|
|
76
76
|
grants?: readonly AgentGrantablePermission[];
|
|
77
|
+
/**
|
|
78
|
+
* Set ONLY by `agentTenant` (#8514 D4); a person's tenant never carries it.
|
|
79
|
+
* `@working-theory/validation`'s `roleAllows` / `allows` read it to withhold
|
|
80
|
+
* `AGENT_WITHHELD_PERMISSIONS` from an agent's role, and both tenant guards
|
|
81
|
+
* refuse an agent request whose tenant lacks it (fail closed).
|
|
82
|
+
*/
|
|
83
|
+
agent?: true;
|
|
77
84
|
}
|
|
78
85
|
|
|
79
86
|
export type TenantErrorKind = "forbidden" | "not_found" | "conflict";
|
|
@@ -222,7 +229,9 @@ export async function resolveDefaultTenantForOperator(
|
|
|
222
229
|
* An agent's tenant (#3591 D5, #7864): its person's tenant with the role
|
|
223
230
|
* clamped to the agent's ceiling and, when any survive, the agent's grants
|
|
224
231
|
* that its person's own role holds. `grants` is omitted when none survive,
|
|
225
|
-
* so an agent with no effective grant is the same shape as before.
|
|
232
|
+
* so an agent with no effective grant is the same shape as before. It always
|
|
233
|
+
* carries `agent: true` (#8514 D4), which withholds the client-keeping rows a
|
|
234
|
+
* member holds by role.
|
|
226
235
|
*
|
|
227
236
|
* The one clamp both tenant guards apply — `resolveAgentTenant` (multi-tenant)
|
|
228
237
|
* and `resolveDefaultTenantForAgent` (base build) — so the two builds cannot
|
|
@@ -238,8 +247,8 @@ export function agentTenant(
|
|
|
238
247
|
(p) => agent.grants.includes(p) && can(personTenant.role, p),
|
|
239
248
|
);
|
|
240
249
|
return grants.length > 0
|
|
241
|
-
? { ...personTenant, role, grants }
|
|
242
|
-
: { ...personTenant, role };
|
|
250
|
+
? { ...personTenant, role, agent: true, grants }
|
|
251
|
+
: { ...personTenant, role, agent: true };
|
|
243
252
|
}
|
|
244
253
|
|
|
245
254
|
/**
|
|
@@ -114,7 +114,8 @@ function refuse<T = never>(
|
|
|
114
114
|
// keyed on an identity the request has already proved (the user the cookie
|
|
115
115
|
// session resolved to; the client that passed secret AND spoke key), so an
|
|
116
116
|
// invented key never gets one (SEC-7894-8). The shape of `middleware/auth.ts`'s
|
|
117
|
-
// per-agent limiter. SINGLE-REPLICA, like
|
|
117
|
+
// per-agent limiter. SINGLE-REPLICA, like the in-process limiters in this
|
|
118
|
+
// app (the per-IP middleware shares a database store, #5040).
|
|
118
119
|
|
|
119
120
|
interface Bucket {
|
|
120
121
|
windowStartMs: number;
|
|
@@ -352,14 +352,33 @@ export async function resolvePublicContentAdapter(
|
|
|
352
352
|
if (site.kind === "not-required" || resolvedAdapterKind() !== "database") {
|
|
353
353
|
return getContentAdapter();
|
|
354
354
|
}
|
|
355
|
+
return workspaceContentAdapter(site.workspaceId);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The content adapter holding ONE workspace's own pages, or `null` when no
|
|
360
|
+
* content is that workspace's (#8514 D9, C13). On the database adapter it is
|
|
361
|
+
* that workspace's database adapter. Off it, the shared content is the
|
|
362
|
+
* deployment's one site — the default workspace's — so only that workspace
|
|
363
|
+
* gets it and every other workspace gets `null`, never the hub's pages
|
|
364
|
+
* (SEC-8514-I). The Admin's plan picker reads its offerings through it.
|
|
365
|
+
*/
|
|
366
|
+
export async function workspaceContentAdapter(
|
|
367
|
+
workspaceId: string,
|
|
368
|
+
): Promise<ContentAdapter | null> {
|
|
369
|
+
if (resolvedAdapterKind() !== "database") {
|
|
370
|
+
// Deferred — see the module-load note in this file's header (#5852 D6).
|
|
371
|
+
const { resolveDefaultWorkspaceId } = await import("./default-tenant.js");
|
|
372
|
+
return (await resolveDefaultWorkspaceId()) === workspaceId ? getContentAdapter() : null;
|
|
373
|
+
}
|
|
355
374
|
|
|
356
|
-
// The database path, and the only
|
|
375
|
+
// The database path, and the only one that needs apps/api's client. The
|
|
357
376
|
// import is deferred for the reason the header gives (#5852 D6), and the
|
|
358
377
|
// generated client is structurally wider than the narrow read surface the
|
|
359
378
|
// adapter declares — the cast names that, and nothing else.
|
|
360
379
|
const { prisma } = await import("../lib/prisma.js");
|
|
361
380
|
return getContentAdapter({
|
|
362
|
-
workspaceId
|
|
381
|
+
workspaceId,
|
|
363
382
|
prisma: prisma as unknown as ArtifactPrismaClient,
|
|
364
383
|
});
|
|
365
384
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 2,
|
|
3
|
-
"ignoreCommand": "[ -n \"$VERCEL_GIT_PREVIOUS_SHA\" ] && cd ../.. && git diff --quiet \"$VERCEL_GIT_PREVIOUS_SHA\" HEAD -- apps/api packages database pnpm-lock.yaml || exit 1",
|
|
3
|
+
"ignoreCommand": "[ -n \"$VERCEL_GIT_PREVIOUS_SHA\" ] && cd ../.. && git diff --quiet \"$VERCEL_GIT_PREVIOUS_SHA\" HEAD -- apps/api packages database pnpm-lock.yaml package.json pnpm-workspace.yaml turbo.json 'tsconfig*.json' || exit 1",
|
|
4
4
|
"buildCommand": "cd ../.. && pnpm run --filter @working-theory/api build:vercel",
|
|
5
5
|
"installCommand": "cd ../.. && pnpm install --frozen-lockfile",
|
|
6
6
|
"outputDirectory": "public",
|