@mohasinac/appkit 4.38.0 → 4.39.1

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 (53) hide show
  1. package/dist/_internal/client/features/digital-code/CodeRevealPanel.d.ts +20 -1
  2. package/dist/_internal/client/features/digital-code/CodeRevealPanel.js +21 -1
  3. package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.d.ts +40 -0
  4. package/dist/_internal/client/features/digital-code/DigitalContentPoolManager.js +67 -0
  5. package/dist/_internal/server/features/checkout/actions.js +18 -0
  6. package/dist/_internal/server/features/digital-code/download.d.ts +60 -0
  7. package/dist/_internal/server/features/digital-code/download.js +128 -0
  8. package/dist/_internal/server/features/digital-code/pool.d.ts +101 -0
  9. package/dist/_internal/server/features/digital-code/pool.js +208 -0
  10. package/dist/_internal/server/features/digital-code/upload.d.ts +51 -0
  11. package/dist/_internal/server/features/digital-code/upload.js +144 -0
  12. package/dist/_internal/server/jobs/core/cleanupRtdbEvents.js +40 -0
  13. package/dist/_internal/server/jobs/core/countersReconcile.js +153 -40
  14. package/dist/_internal/server/jobs/core/onCategoryWrite.js +33 -0
  15. package/dist/_internal/server/jobs/core/onProductWrite.js +93 -25
  16. package/dist/_internal/server/notifications/send-recorder.d.ts +79 -0
  17. package/dist/_internal/server/notifications/send-recorder.js +126 -0
  18. package/dist/client.d.ts +2 -0
  19. package/dist/client.js +6 -0
  20. package/dist/features/blog/components/BlogIndexPageView.js +10 -1
  21. package/dist/features/blog/repository/blog.repository.d.ts +14 -0
  22. package/dist/features/blog/repository/blog.repository.js +23 -1
  23. package/dist/features/categories/components/CategoryDetailPageView.js +1 -1
  24. package/dist/features/categories/components/CategoryTree.js +1 -1
  25. package/dist/features/categories/repository/categories.repository.d.ts +34 -2
  26. package/dist/features/categories/repository/categories.repository.js +31 -9
  27. package/dist/features/contact/email.js +36 -1
  28. package/dist/features/orders/repository/orders.repository.d.ts +21 -1
  29. package/dist/features/orders/repository/orders.repository.js +26 -9
  30. package/dist/features/products/repository/products.repository.d.ts +35 -0
  31. package/dist/features/products/repository/products.repository.js +116 -3
  32. package/dist/features/products/schemas/firestore.d.ts +37 -0
  33. package/dist/features/reviews/repository/reviews.repository.d.ts +1 -1
  34. package/dist/features/reviews/repository/reviews.repository.js +2 -2
  35. package/dist/features/seller/actions/seller-actions.js +4 -13
  36. package/dist/features/seller/components/SellerProductShell.d.ts +7 -1
  37. package/dist/features/seller/components/SellerProductShell.js +11 -2
  38. package/dist/features/stores/api/[storeSlug]/reviews/route.d.ts +6 -1
  39. package/dist/features/stores/api/[storeSlug]/reviews/route.js +134 -38
  40. package/dist/features/stores/components/StoresIndexPageView.js +17 -1
  41. package/dist/features/tester/seed-data/authored/admin__users-trust.js +1 -1
  42. package/dist/features/tester/seed-data/authored/buying__offers.js +1 -1
  43. package/dist/features/tester/seed-data/authored/buying__user-dashboard-extras.js +6 -6
  44. package/dist/features/tester/utils/phases.js +78 -12
  45. package/dist/providers/db-firebase/rtdb-paths.d.ts +7 -0
  46. package/dist/providers/db-firebase/rtdb-paths.js +7 -0
  47. package/dist/server.d.ts +3 -0
  48. package/dist/server.js +12 -0
  49. package/dist/ui/components/FilePickerButton.d.ts +32 -0
  50. package/dist/ui/components/FilePickerButton.js +23 -0
  51. package/dist/ui/index.d.ts +2 -0
  52. package/dist/ui/index.js +7 -0
  53. package/package.json +1 -1
@@ -1,6 +1,25 @@
1
1
  import React from "react";
2
+ /**
3
+ * What the buyer was actually delivered.
4
+ *
5
+ * A "digital code" listing delivers digital CONTENT — a redemption string is
6
+ * only its commonest shape. `contentKind` is optional and absent means `"code"`,
7
+ * so a delivery recorded before assets existed renders exactly as it always did.
8
+ */
2
9
  export interface RevealedCode {
3
- code: string;
10
+ contentKind?: "code" | "image" | "file";
11
+ /** Present only for `contentKind: "code"`. */
12
+ code?: string;
13
+ /**
14
+ * Present for the asset kinds. Points at the AUTHENTICATED download route,
15
+ * never at Storage and never at `/media/` — see the route's own header for
16
+ * why that distinction is the whole security model here.
17
+ */
18
+ downloadUrl?: string;
19
+ fileName?: string;
20
+ contentType?: string;
21
+ /** Seller-authored redemption help, carried by the reveal response. */
22
+ redemptionInstructions?: string;
4
23
  orderId: string;
5
24
  claimedAt?: Date | string;
6
25
  expiresAt?: Date | string;
@@ -36,5 +36,25 @@ export function CodeRevealPanel({ orderId, redemptionInstructions, fetchCode, })
36
36
  setCopied(true);
37
37
  setTimeout(() => setCopied(false), 2000);
38
38
  }
39
- return (_jsx(Div, { className: `border border-border bg-muted/40 ${__P.p4}`, rounded: "lg", children: _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { weight: "medium", children: "Your Digital Code" }), !revealed ? (_jsxs(Stack, { gap: "sm", children: [error && _jsx(Text, { className: "text-destructive", size: "sm", children: error }), _jsx(Button, { type: "button", variant: "primary", size: "lg", isLoading: pending, disabled: pending, onClick: handleReveal, className: "w-full", children: "Reveal Code" })] })) : (_jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { textSize: "lg", className: "border border-border bg-background font-mono", padding: "inlineSm", align: "center", gap: "sm", rounded: "md", children: [_jsx(Span, { className: "flex-1 select-all", children: revealed.code }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: handleCopy, className: "shrink-0", children: copied ? "Copied!" : "Copy" })] }), redemptionInstructions && (_jsx(Text, { className: "text-muted-foreground", size: "sm", children: redemptionInstructions }))] }))] }) }));
39
+ return (_jsx(Div, { className: `border border-border bg-muted/40 ${__P.p4}`, rounded: "lg", children: _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { weight: "medium", children: revealed && (revealed.contentKind ?? "code") !== "code"
40
+ ? "Your Digital Content"
41
+ : "Your Digital Code" }), !revealed ? (_jsxs(Stack, { gap: "sm", children: [error && _jsx(Text, { className: "text-destructive", size: "sm", children: error }), _jsx(Button, { type: "button", variant: "primary", size: "lg", isLoading: pending, disabled: pending, onClick: handleReveal, className: "w-full", children: "Reveal Code" })] })) : (_jsxs(Stack, { gap: "sm", children: [(revealed.contentKind ?? "code") === "code" ? (_jsxs(Row, { textSize: "lg", className: "border border-border bg-background font-mono", padding: "inlineSm", align: "center", gap: "sm", rounded: "md", children: [_jsx(Span, { className: "flex-1 select-all", children: revealed.code }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: handleCopy, className: "shrink-0", children: copied ? "Copied!" : "Copy" })] })) : (
42
+ /*
43
+ * 🛑 A LINK, not an <img>/<MediaImage>. Rendering the asset inline
44
+ * would need a URL a browser can GET without our session
45
+ * semantics, and this one is a `private, no-store` authenticated
46
+ * download that always answers `Content-Disposition: attachment`.
47
+ * An <img src> pointed at it would show a broken image, and
48
+ * "fixing" that by moving the bytes somewhere renderable is
49
+ * precisely what publishes a paid good — see the download route's
50
+ * header.
51
+ *
52
+ * Not a <Button onClick={fetch}> either: a same-origin anchor lets
53
+ * the browser own the download, including resume and the file
54
+ * name, and never materialises the bytes in JS memory.
55
+ */
56
+ _jsxs(Row, { className: "border border-border bg-background", padding: "inlineSm", align: "center", gap: "sm", rounded: "md", children: [_jsx(Span, { className: "flex-1 truncate", children: revealed.fileName ?? "Your download" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", className: "shrink-0", onClick: () => {
57
+ if (revealed.downloadUrl)
58
+ window.location.assign(revealed.downloadUrl);
59
+ }, children: "Download" })] })), (redemptionInstructions ?? revealed.redemptionInstructions) && (_jsx(Text, { className: "text-muted-foreground", size: "sm", children: redemptionInstructions ?? revealed.redemptionInstructions }))] }))] }) }));
40
60
  }
@@ -0,0 +1,40 @@
1
+ import React from "react";
2
+ /**
3
+ * The seller's view of what a digital-code listing will actually deliver.
4
+ *
5
+ * 🛑 WHY THIS COMPONENT HAD TO EXIST FOR THE FEATURE TO WORK AT ALL. The pool
6
+ * at `products/{id}/codes` is what `claimDigitalCodeForOrder` hands a buyer, and
7
+ * until 2026-09-14 nothing in the product could write to it — its one route
8
+ * answered 501. So the pool was always empty, every purchase logged "code pool
9
+ * exhausted" and completed anyway, and the buyer's reveal panel 404'd. Shipping
10
+ * the routes without this surface would have left the same gap one layer up: a
11
+ * working API nobody can reach is Root Cause #37's shape.
12
+ *
13
+ * Edit mode only — a pool is keyed on a product id, which does not exist until
14
+ * the listing has been created once.
15
+ */
16
+ export interface PoolEntry {
17
+ id: string;
18
+ contentKind: "code" | "image" | "file";
19
+ status: string;
20
+ fileName?: string;
21
+ orderId?: string;
22
+ claimedAt?: string;
23
+ createdAt?: string;
24
+ }
25
+ export interface DigitalContentPoolManagerProps {
26
+ productId: string;
27
+ /**
28
+ * Injected so this component never hard-codes an API path — the same contract
29
+ * `CodeRevealPanel` uses. The consumer owns the transport.
30
+ */
31
+ api: {
32
+ list: (productId: string) => Promise<PoolEntry[]>;
33
+ addCodes: (productId: string, codes: string[]) => Promise<unknown>;
34
+ addAsset: (productId: string, file: File) => Promise<unknown>;
35
+ remove: (productId: string, entryId: string) => Promise<unknown>;
36
+ };
37
+ /** Non-image files are staff-only to upload; hide the affordance otherwise. */
38
+ canUploadFiles?: boolean;
39
+ }
40
+ export declare function DigitalContentPoolManager({ productId, api, canUploadFiles, }: DigitalContentPoolManagerProps): React.JSX.Element;
@@ -0,0 +1,67 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { normalizeError } from "../../../../errors/normalize";
5
+ import { Alert, Button, Div, Row, Span, Stack, Text } from "../../../../ui";
6
+ import { FilePickerButton } from "../../../../ui/components/FilePickerButton";
7
+ import { FieldTextarea } from "../../../../ui/forms/FieldTextarea";
8
+ const KIND_LABEL = {
9
+ code: "Code",
10
+ image: "QR / image",
11
+ file: "File",
12
+ };
13
+ export function DigitalContentPoolManager({ productId, api, canUploadFiles = false, }) {
14
+ const [entries, setEntries] = useState(null);
15
+ const [bulk, setBulk] = useState("");
16
+ const [busy, setBusy] = useState(false);
17
+ const [error, setError] = useState(null);
18
+ const refresh = useCallback(async () => {
19
+ try {
20
+ setEntries(await api.list(productId));
21
+ setError(null);
22
+ }
23
+ catch (e) {
24
+ /*
25
+ * Surface it. An empty list and a failed list look identical, and "this
26
+ * listing has no codes" is exactly the wrong thing to tell a seller whose
27
+ * pool is full — the same confusion that made the 501 invisible.
28
+ */
29
+ setError(normalizeError(e).message || "Could not load the pool.");
30
+ setEntries([]);
31
+ }
32
+ }, [api, productId]);
33
+ useEffect(() => {
34
+ void refresh();
35
+ }, [refresh]);
36
+ async function run(fn) {
37
+ setBusy(true);
38
+ setError(null);
39
+ try {
40
+ await fn();
41
+ await refresh();
42
+ }
43
+ catch (e) {
44
+ setError(normalizeError(e).message || "That did not work.");
45
+ }
46
+ finally {
47
+ setBusy(false);
48
+ }
49
+ }
50
+ const available = (entries ?? []).filter((e) => e.status === "available").length;
51
+ const claimed = (entries ?? []).filter((e) => e.status === "claimed").length;
52
+ return (_jsxs(Stack, { gap: "md", children: [_jsx(Alert, { variant: "info", children: "Whatever you add here is what a buyer receives after paying. A listing with an empty pool can still be bought \u2014 and the buyer gets nothing \u2014 so add at least one entry before publishing." }), error && _jsx(Alert, { variant: "error", children: error }), _jsxs(Row, { gap: "md", align: "center", children: [_jsxs(Text, { size: "sm", children: [_jsx(Span, { weight: "semibold", children: available }), " available"] }), _jsxs(Text, { size: "sm", color: "muted", children: [_jsx(Span, { weight: "semibold", children: claimed }), " already delivered"] })] }), _jsx(FieldTextarea, { name: "bulkCodes", label: "Add codes", value: bulk, onChange: setBulk, rows: 4, placeholder: "ONE-CODE-PER-LINE\nANOTHER-CODE" }), _jsx(Text, { size: "xs", color: "muted", children: "One per line. Blank lines and duplicates within the paste are ignored." }), _jsxs(Row, { gap: "sm", children: [_jsx(Button, { type: "button", variant: "primary", size: "sm", isLoading: busy, disabled: busy || bulk.trim().length === 0, onClick: () => {
53
+ // De-duplicate the paste itself. Re-pasting a list is the obvious
54
+ // way to double a pool by accident, and a duplicated code is a
55
+ // second buyer receiving something already sold.
56
+ const codes = [
57
+ ...new Set(bulk
58
+ .split("\n")
59
+ .map((l) => l.trim())
60
+ .filter(Boolean)),
61
+ ];
62
+ void run(async () => {
63
+ await api.addCodes(productId, codes);
64
+ setBulk("");
65
+ });
66
+ }, children: "Add codes" }), _jsx(FilePickerButton, { variant: "secondary", size: "sm", disabled: busy, accept: canUploadFiles ? undefined : "image/*", onFile: (file) => void run(() => api.addAsset(productId, file)), children: canUploadFiles ? "Upload QR or file" : "Upload QR image" })] }), _jsx(Div, { children: entries === null ? (_jsx(Text, { size: "sm", color: "muted", children: "Loading\u2026" })) : entries.length === 0 ? (_jsx(Text, { size: "sm", color: "muted", children: "Nothing in the pool yet." })) : (_jsx(Stack, { gap: "xs", children: entries.map((e) => (_jsxs(Row, { gap: "sm", align: "center", justify: "between", children: [_jsxs(Text, { size: "sm", children: [KIND_LABEL[e.contentKind], e.fileName ? ` — ${e.fileName}` : "", _jsxs(Span, { color: "muted", children: [" \u00B7 ", e.status] })] }), e.status !== "claimed" && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", disabled: busy, onClick: () => void run(() => api.remove(productId, e.id)), children: "Remove" }))] }, e.id))) })) })] }));
67
+ }
@@ -73,6 +73,24 @@ async function claimDigitalCodeForOrder(db, productId, orderId, userId, opts) {
73
73
  });
74
74
  claimed = true;
75
75
  });
76
+ if (claimed) {
77
+ /*
78
+ * Re-derive both pool counters. Nothing used to do this: the claim flipped a
79
+ * code to `claimed` and left `digitalCode.codesAvailable` exactly as the
80
+ * seller had typed it, so the availability predicate — which reads that
81
+ * counter — could never notice a listing selling out. Counting beats
82
+ * decrementing here because the counter was never trustworthy to begin with
83
+ * (Root Cause #42).
84
+ *
85
+ * Deliberately awaited but non-fatal inside: a failed recount must not undo
86
+ * a claim the buyer has already paid for.
87
+ */
88
+ const { recountPool } = await import("../digital-code/pool");
89
+ await recountPool(productId).catch((e) => serverLogger.error("claimDigitalCode: recount failed", {
90
+ error: normalizeError(e).message,
91
+ productId,
92
+ }));
93
+ }
76
94
  if (claimed && opts?.userEmail) {
77
95
  const { sendDigitalCodeClaimedEmail } = await import("../../../../features/contact/server");
78
96
  sendDigitalCodeClaimedEmail({
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Streams the digital ASSET a buyer paid for.
3
+ *
4
+ * 🛑 WHY THIS ROUTE EXISTS AT ALL, rather than a `/media/{slug}` URL.
5
+ *
6
+ * Two independent facts make the ordinary media pipeline unusable for paid
7
+ * content, and either one alone would be enough:
8
+ *
9
+ * 1. `storage.rules` grants `allow read: if true` on **every object in the
10
+ * bucket**. The comment above it explains why that is acceptable — the
11
+ * rules are a safety net for a misconfigured client, all real access goes
12
+ * through the Admin SDK — but it means Storage itself protects nothing.
13
+ * 2. `GET /api/media/[...slug]` performs **no authentication whatsoever**. It
14
+ * is a bare exported handler with no session read, deliberately, because
15
+ * its job is to serve public product photography through a watermarker.
16
+ * It also resolves legacy multi-segment slugs as raw storage paths.
17
+ *
18
+ * Together: anything reachable at a `/media/` slug is world-readable, and this
19
+ * codebase's media filenames are content-derived and therefore guessable. A
20
+ * purchased QR living there would be a paid good at a predictable public URL.
21
+ *
22
+ * So the bytes live under a private prefix that mints no `mediaAssets` row (and
23
+ * therefore no slug), and are streamed only from here — after re-checking that
24
+ * the caller owns the order. That is the same shape as
25
+ * `GET /api/user/orders/[id]/invoice`, the one existing precedent in this
26
+ * codebase for a gated download.
27
+ */
28
+ /** Order statuses at which delivered content may be downloaded. */
29
+ export declare const CODE_DOWNLOAD_STATUSES: Set<string>;
30
+ export interface CodeAssetResolution {
31
+ ok: boolean;
32
+ /** HTTP status to answer with when `ok` is false. */
33
+ status?: number;
34
+ message?: string;
35
+ buffer?: Buffer;
36
+ contentType?: string;
37
+ fileName?: string;
38
+ }
39
+ /**
40
+ * Resolve and read the asset for `orderId`, or explain why not.
41
+ *
42
+ * `viewerUid` is the authenticated caller. `isStaff` lets admin/moderator open a
43
+ * buyer's delivery for support — the same escape hatch the invoice route has.
44
+ */
45
+ export declare function resolveCodeAssetForOrder(orderId: string, viewerUid: string, isStaff: boolean): Promise<CodeAssetResolution>;
46
+ /**
47
+ * The response headers for a delivered asset.
48
+ *
49
+ * 🛑 `attachment`, ALWAYS — even for an image, where `inline` would be friendlier.
50
+ * The upload surface accepts files, `finalize`'s magic-byte check proves what a
51
+ * file IS but not that rendering it is safe, and an HTML document served inline
52
+ * from this origin is stored XSS against a signed-in buyer. `nosniff` closes the
53
+ * other half (a browser guessing `text/html` from content it was told was
54
+ * `application/octet-stream`).
55
+ *
56
+ * `private, no-store` because a shared cache must never hold one buyer's
57
+ * purchase — this URL is identical for every caller and only the session
58
+ * distinguishes them.
59
+ */
60
+ export declare function codeAssetHeaders(contentType: string, fileName: string): Record<string, string>;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Streams the digital ASSET a buyer paid for.
3
+ *
4
+ * 🛑 WHY THIS ROUTE EXISTS AT ALL, rather than a `/media/{slug}` URL.
5
+ *
6
+ * Two independent facts make the ordinary media pipeline unusable for paid
7
+ * content, and either one alone would be enough:
8
+ *
9
+ * 1. `storage.rules` grants `allow read: if true` on **every object in the
10
+ * bucket**. The comment above it explains why that is acceptable — the
11
+ * rules are a safety net for a misconfigured client, all real access goes
12
+ * through the Admin SDK — but it means Storage itself protects nothing.
13
+ * 2. `GET /api/media/[...slug]` performs **no authentication whatsoever**. It
14
+ * is a bare exported handler with no session read, deliberately, because
15
+ * its job is to serve public product photography through a watermarker.
16
+ * It also resolves legacy multi-segment slugs as raw storage paths.
17
+ *
18
+ * Together: anything reachable at a `/media/` slug is world-readable, and this
19
+ * codebase's media filenames are content-derived and therefore guessable. A
20
+ * purchased QR living there would be a paid good at a predictable public URL.
21
+ *
22
+ * So the bytes live under a private prefix that mints no `mediaAssets` row (and
23
+ * therefore no slug), and are streamed only from here — after re-checking that
24
+ * the caller owns the order. That is the same shape as
25
+ * `GET /api/user/orders/[id]/invoice`, the one existing precedent in this
26
+ * codebase for a gated download.
27
+ */
28
+ import { getAdminDb } from "../../../../providers/db-firebase/admin";
29
+ import { getAdminStorage } from "../../../../providers/db-firebase";
30
+ import { normalizeError } from "../../../../errors/normalize";
31
+ import { ORDER_FIELDS } from "../../../../constants/field-names";
32
+ import { PRODUCT_COLLECTION, PRODUCT_CODES_SUBCOLLECTION, } from "../../../../features/products/schemas/firestore";
33
+ /** Order statuses at which delivered content may be downloaded. */
34
+ export const CODE_DOWNLOAD_STATUSES = new Set([
35
+ "confirmed",
36
+ "processing",
37
+ "delivered",
38
+ ]);
39
+ /**
40
+ * Resolve and read the asset for `orderId`, or explain why not.
41
+ *
42
+ * `viewerUid` is the authenticated caller. `isStaff` lets admin/moderator open a
43
+ * buyer's delivery for support — the same escape hatch the invoice route has.
44
+ */
45
+ export async function resolveCodeAssetForOrder(orderId, viewerUid, isStaff) {
46
+ const db = getAdminDb();
47
+ const orderSnap = await db.collection("orders").doc(orderId).get();
48
+ if (!orderSnap.exists) {
49
+ return { ok: false, status: 404, message: "Order not found" };
50
+ }
51
+ const order = orderSnap.data();
52
+ /*
53
+ * 404, not 403, when it is not your order — matching the reveal route and the
54
+ * order-invoice route. Answering 403 would confirm that an order with that id
55
+ * exists, which is a membership oracle over a guessable id space.
56
+ */
57
+ if (!isStaff && order.userId !== viewerUid) {
58
+ return { ok: false, status: 404, message: "Order not found" };
59
+ }
60
+ if (!CODE_DOWNLOAD_STATUSES.has(String(order.status ?? "").toLowerCase())) {
61
+ return {
62
+ ok: false,
63
+ status: 400,
64
+ message: "This order is not in a state where content can be downloaded.",
65
+ };
66
+ }
67
+ const productId = order.items?.[0]?.productId ?? order.productId;
68
+ if (!productId) {
69
+ return { ok: false, status: 404, message: "No content found for this order" };
70
+ }
71
+ const snap = await db
72
+ .collection(PRODUCT_COLLECTION)
73
+ .doc(productId)
74
+ .collection(PRODUCT_CODES_SUBCOLLECTION)
75
+ .where("orderId", "==", orderId)
76
+ .where(ORDER_FIELDS.STATUS, "==", "claimed")
77
+ .limit(1)
78
+ .get();
79
+ if (snap.empty) {
80
+ return { ok: false, status: 404, message: "No content found for this order" };
81
+ }
82
+ const entry = snap.docs[0].data();
83
+ if (!entry.assetPath) {
84
+ return {
85
+ ok: false,
86
+ status: 400,
87
+ message: "This delivery is a code, not a downloadable file.",
88
+ };
89
+ }
90
+ try {
91
+ const file = getAdminStorage().bucket().file(entry.assetPath);
92
+ const [buffer] = await file.download();
93
+ return {
94
+ ok: true,
95
+ buffer,
96
+ contentType: entry.contentType ?? "application/octet-stream",
97
+ fileName: entry.fileName ?? "download",
98
+ };
99
+ }
100
+ catch (err) {
101
+ void normalizeError(err);
102
+ return { ok: false, status: 404, message: "Content is no longer available" };
103
+ }
104
+ }
105
+ /**
106
+ * The response headers for a delivered asset.
107
+ *
108
+ * 🛑 `attachment`, ALWAYS — even for an image, where `inline` would be friendlier.
109
+ * The upload surface accepts files, `finalize`'s magic-byte check proves what a
110
+ * file IS but not that rendering it is safe, and an HTML document served inline
111
+ * from this origin is stored XSS against a signed-in buyer. `nosniff` closes the
112
+ * other half (a browser guessing `text/html` from content it was told was
113
+ * `application/octet-stream`).
114
+ *
115
+ * `private, no-store` because a shared cache must never hold one buyer's
116
+ * purchase — this URL is identical for every caller and only the session
117
+ * distinguishes them.
118
+ */
119
+ export function codeAssetHeaders(contentType, fileName) {
120
+ // Strip anything that could break out of the quoted filename or inject a header.
121
+ const safeName = fileName.replace(/[^\w.\-() ]+/g, "_").slice(0, 120) || "download";
122
+ return {
123
+ "Content-Type": contentType,
124
+ "Content-Disposition": `attachment; filename="${safeName}"`,
125
+ "X-Content-Type-Options": "nosniff",
126
+ "Cache-Control": "private, no-store, max-age=0",
127
+ };
128
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The digital-content pool for a `digital-code` listing.
3
+ *
4
+ * 🛑 THIS IS THE MISSING HALF OF THE FEATURE. Everything downstream already
5
+ * existed — `claimDigitalCodeForOrder` claims one at checkout, the buyer reveals
6
+ * it through `GET /api/orders/{id}/code`, a refund revokes unclaimed ones — but
7
+ * **nothing in the codebase had ever WRITTEN a row into the pool.** The one
8
+ * seller-facing route returned `501 "Digital code management is not implemented
9
+ * yet."` So every purchase of a digital-code listing hit
10
+ * `serverLogger.warn("claimDigitalCode: code pool exhausted")` and returned
11
+ * silently, the order completed normally, and the buyer's reveal panel answered
12
+ * 404. The seller form even has a "Code Pool Size" field, which made the pool
13
+ * look populated on the listing page while it was always empty.
14
+ *
15
+ * A "digital code" delivers digital CONTENT: a redemption string, a QR image, or
16
+ * a file. `contentKind` says which, defaulting to `"code"` so every pre-existing
17
+ * document keeps its meaning with no migration.
18
+ */
19
+ import { type ProductCodeContentKind } from "../../../../features/products/schemas/firestore";
20
+ /**
21
+ * Where a delivered asset's bytes live.
22
+ *
23
+ * Deliberately OUTSIDE every prefix the public media proxy knows how to resolve,
24
+ * and no `mediaAssets` row is ever created for it — a `mediaAssets` row is what
25
+ * mints a `/media/{shortId}` slug, and that slug is served without
26
+ * authentication. No row, no slug, no public URL.
27
+ */
28
+ export declare const DIGITAL_CONTENT_PREFIX = "private/digital-content";
29
+ /**
30
+ * 🛑 THE PERMISSION SPLIT, and the reason it is not simply "admins only".
31
+ *
32
+ * An image is the QR/voucher case and is the whole point of widening this from
33
+ * strings, so a seller must be able to add one — they are the person who has it.
34
+ * Any OTHER file type is admin-only to upload: the upload surface accepts bytes
35
+ * that will later be streamed to a buyer, and a `.html` renamed `.pdf` is stored
36
+ * XSS the moment anything renders it inline. Narrowing who can put a non-image
37
+ * file into that path is the cheap half of the defence; the magic-byte check and
38
+ * the forced `Content-Disposition: attachment` are the other half.
39
+ *
40
+ * READ is a third thing again and is not on this axis at all: whoever may
41
+ * upload, only the buyer who paid for that order (or staff) may download. See
42
+ * `assertCanDownloadCodeAsset`.
43
+ */
44
+ export declare function assertCanUploadContentKind(kind: ProductCodeContentKind, role: string | undefined): void;
45
+ export interface AddPoolEntryInput {
46
+ productId: string;
47
+ contentKind: ProductCodeContentKind;
48
+ /** Required when `contentKind === "code"`. */
49
+ code?: string;
50
+ /** Required for the asset kinds — a raw Storage path under DIGITAL_CONTENT_PREFIX. */
51
+ assetPath?: string;
52
+ fileName?: string;
53
+ contentType?: string;
54
+ expiresAt?: Date;
55
+ }
56
+ /**
57
+ * Append entries to a product's pool and re-sync the two counters.
58
+ *
59
+ * `codePoolSize` and `codesAvailable` are recomputed from the subcollection, not
60
+ * incremented from the input — the seller form lets a human type `codePoolSize`
61
+ * by hand, so treating that number as authoritative would let the listing page
62
+ * advertise stock that does not exist. Counting is the only thing that cannot
63
+ * drift (Root Cause #42: a mirror is only as good as every writer of it).
64
+ */
65
+ export declare function addPoolEntries(entries: AddPoolEntryInput[]): Promise<{
66
+ added: number;
67
+ available: number;
68
+ total: number;
69
+ }>;
70
+ /**
71
+ * Recount the pool and write both counters onto the product.
72
+ *
73
+ * 🛑 `codesAvailable` HAD NO WRITER EITHER. `claimDigitalCodeForOrder` flips a
74
+ * code to `claimed` and never decrements it, so the counter only ever held
75
+ * whatever the seed or the seller typed. Since `hasCodesAvailable()` is what the
76
+ * availability predicate reads, a sold-out listing stayed "available" forever and
77
+ * a listing that never had codes still advertised stock. Both counters are
78
+ * derived here, so neither can drift from the subcollection.
79
+ */
80
+ export declare function recountPool(productId: string): Promise<{
81
+ available: number;
82
+ total: number;
83
+ }>;
84
+ /** The seller/admin view of a pool. Never returns `code` or `assetPath`. */
85
+ export declare function listPoolEntries(productId: string): Promise<Array<{
86
+ id: string;
87
+ contentKind: ProductCodeContentKind;
88
+ status: string;
89
+ fileName?: string;
90
+ orderId?: string;
91
+ claimedAt?: string;
92
+ createdAt?: string;
93
+ }>>;
94
+ /**
95
+ * Remove an UNCLAIMED entry. A claimed one is somebody's purchase and deleting
96
+ * it would make their reveal 404 with no trace of what they bought.
97
+ */
98
+ export declare function deletePoolEntry(productId: string, codeId: string): Promise<{
99
+ available: number;
100
+ total: number;
101
+ }>;