create-nextblock 0.15.8 → 0.15.9
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 +1 -1
- package/templates/nextblock-template/app/ToasterProvider.tsx +26 -17
- package/templates/nextblock-template/app/actions/contactSellerActions.test.ts +280 -0
- package/templates/nextblock-template/app/actions/contactSellerActions.ts +222 -0
- package/templates/nextblock-template/app/actions/email-retry.test.ts +62 -0
- package/templates/nextblock-template/app/actions/email.ts +241 -110
- package/templates/nextblock-template/app/actions/formActions.ts +245 -116
- package/templates/nextblock-template/app/actions/interactions.ts +489 -396
- package/templates/nextblock-template/app/actions/threadActions.ts +166 -0
- package/templates/nextblock-template/app/api/checkout/route.ts +162 -146
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +664 -1
- package/templates/nextblock-template/app/checkout/page.tsx +57 -52
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +552 -529
- package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +304 -181
- package/templates/nextblock-template/app/cms/components/ContactReminderBanner.tsx +75 -0
- package/templates/nextblock-template/app/cms/components/PaymentsReminderBanner.tsx +58 -0
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +542 -528
- package/templates/nextblock-template/app/cms/inquiries/actions.ts +66 -0
- package/templates/nextblock-template/app/cms/inquiries/page.tsx +12 -0
- package/templates/nextblock-template/app/cms/interactions/page.tsx +12 -51
- package/templates/nextblock-template/app/cms/layout.tsx +101 -73
- package/templates/nextblock-template/app/cms/messages/MessagesClient.tsx +661 -0
- package/templates/nextblock-template/app/cms/messages/actions.ts +404 -0
- package/templates/nextblock-template/app/cms/messages/loadInbox.ts +333 -0
- package/templates/nextblock-template/app/cms/messages/page.tsx +87 -0
- package/templates/nextblock-template/app/cms/messages/require-admin.ts +37 -0
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +370 -362
- package/templates/nextblock-template/app/cms/revisions/service.ts +20 -0
- package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +227 -185
- package/templates/nextblock-template/app/layout.tsx +671 -671
- package/templates/nextblock-template/app/product/[slug]/page.tsx +502 -482
- package/templates/nextblock-template/app/providers.tsx +96 -96
- package/templates/nextblock-template/app/thread/ThreadView.tsx +164 -0
- package/templates/nextblock-template/app/thread/[token]/route.ts +57 -0
- package/templates/nextblock-template/app/thread/layout.tsx +15 -0
- package/templates/nextblock-template/app/thread/page.tsx +98 -0
- package/templates/nextblock-template/components/BlockRenderer.tsx +312 -296
- package/templates/nextblock-template/components/ContactSellerSection.tsx +188 -0
- package/templates/nextblock-template/components/PostCommentsSection.tsx +378 -369
- package/templates/nextblock-template/components/ProductReviewsSection.tsx +426 -419
- package/templates/nextblock-template/components/StaffReplies.tsx +102 -0
- package/templates/nextblock-template/components/blocks/renderers/CartBlockRenderer.tsx +18 -17
- package/templates/nextblock-template/components/blocks/renderers/CheckoutBlockRenderer.tsx +20 -19
- package/templates/nextblock-template/components/blocks/renderers/FeaturedProductBlockRenderer.tsx +25 -22
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +385 -381
- package/templates/nextblock-template/components/blocks/renderers/ProductDetailsBlockRenderer.tsx +157 -92
- package/templates/nextblock-template/components/blocks/renderers/ProductGridBlockRenderer.tsx +34 -31
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +612 -600
- package/templates/nextblock-template/components/commerce/PaymentReadinessBoundary.tsx +32 -0
- package/templates/nextblock-template/docs/14-MESSAGES-INBOX.md +309 -0
- package/templates/nextblock-template/docs/README.md +42 -41
- package/templates/nextblock-template/docs/assets/lighthouse-scores.png +0 -0
- package/templates/nextblock-template/lib/blocks/blockColors.test.ts +22 -2
- package/templates/nextblock-template/lib/blocks/blockColors.ts +44 -1
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +761 -753
- package/templates/nextblock-template/lib/cms/contact-reminder.ts +64 -0
- package/templates/nextblock-template/lib/cms/payments-reminder.ts +98 -0
- package/templates/nextblock-template/lib/cms/unread-messages.ts +42 -0
- package/templates/nextblock-template/lib/commerce/seller-contact.ts +162 -0
- package/templates/nextblock-template/lib/config/email-settings.ts +323 -254
- package/templates/nextblock-template/lib/config/email-tls.test.ts +57 -0
- package/templates/nextblock-template/lib/email/placeholder-address.test.ts +59 -0
- package/templates/nextblock-template/lib/email/placeholder-address.ts +39 -0
- package/templates/nextblock-template/lib/messages/thread-reference.test.ts +70 -0
- package/templates/nextblock-template/lib/messages/thread-token.test.ts +93 -0
- package/templates/nextblock-template/lib/messages/thread-token.ts +157 -0
- package/templates/nextblock-template/lib/messages/threads.ts +579 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +20 -0
- package/templates/nextblock-template/lib/site-url.test.ts +89 -0
- package/templates/nextblock-template/lib/site-url.ts +102 -48
- package/templates/nextblock-template/package.json +1 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getStoreReadiness } from '@nextblock-cms/ecommerce/server';
|
|
2
|
+
import { PaymentReadinessProvider } from '@nextblock-cms/ecommerce/PaymentReadinessProvider';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Supplies payment readiness to the buy controls beneath it.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately NOT mounted in the root layout: readiness is only consulted by buy CTAs
|
|
8
|
+
* and the checkout, so a blog post or a landing page should not pay for the lookup. Each
|
|
9
|
+
* commerce block renderer wraps itself in this instead, which also means nested cases
|
|
10
|
+
* (a product grid inside a section) are covered without anyone having to walk the block
|
|
11
|
+
* tree looking for them.
|
|
12
|
+
*
|
|
13
|
+
* getStoreReadiness() is request-cached, so several of these on one page cost one read.
|
|
14
|
+
* Any failure yields no readiness at all, and the context default ("everything ready")
|
|
15
|
+
* keeps a working store working.
|
|
16
|
+
*/
|
|
17
|
+
export default async function PaymentReadinessBoundary({
|
|
18
|
+
children,
|
|
19
|
+
}: {
|
|
20
|
+
children: React.ReactNode;
|
|
21
|
+
}) {
|
|
22
|
+
let readiness: { stripe: boolean; freemius: boolean } | null = null;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const store = await getStoreReadiness();
|
|
26
|
+
readiness = { stripe: store.stripe.ready, freemius: store.freemius.ready };
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error('[PaymentReadinessBoundary] Could not resolve payment readiness:', error);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return <PaymentReadinessProvider readiness={readiness}>{children}</PaymentReadinessProvider>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# 14 · Messages — the unified inbox
|
|
2
|
+
|
|
3
|
+
Everything a visitor sends the site arrives in one place: **CMS → Messages**
|
|
4
|
+
(`/cms/messages`). Four sources feed it, and the admin can reply to all of them from
|
|
5
|
+
there.
|
|
6
|
+
|
|
7
|
+
The inbox is one *view* over **two storage models**, because the sources are genuinely
|
|
8
|
+
different and flattening them would break something real.
|
|
9
|
+
|
|
10
|
+
| Source | Table | Visibility | What a reply is |
|
|
11
|
+
|---|---|---|---|
|
|
12
|
+
| Product enquiry | `message_threads` + `thread_messages` | private | a private message, delivered via a tokenised link |
|
|
13
|
+
| Contact form | `message_threads` + `thread_messages` | private | same |
|
|
14
|
+
| Product review | `cms_interactions` | already public | a **public** reply under the review |
|
|
15
|
+
| Post comment | `cms_interactions` | already public | a **public** reply under the comment |
|
|
16
|
+
|
|
17
|
+
`app/cms/messages/loadInbox.ts` performs the merge in the read path and normalises both
|
|
18
|
+
into an `InboxItem`. The detail pane branches on `item.kind`.
|
|
19
|
+
|
|
20
|
+
## Why not one table
|
|
21
|
+
|
|
22
|
+
`cms_interactions` cannot hold an enquiry, and the reasons are all enforced by the
|
|
23
|
+
schema rather than by convention:
|
|
24
|
+
|
|
25
|
+
- `user_id` is `NOT NULL` with a foreign key to `profiles` — every review and comment
|
|
26
|
+
belongs to a registered account. Enquiry senders are anonymous.
|
|
27
|
+
- `check_product_or_post` requires *exactly one* of `product_id` / `post_id`. A contact
|
|
28
|
+
form targets neither.
|
|
29
|
+
- Reviews and comments are readable by `anon` through an open `SELECT` policy once
|
|
30
|
+
approved. Enquiries carry visitor PII and have **no anon grant or policy at all**.
|
|
31
|
+
- `update_product_ratings()` fires on every `cms_interactions` write.
|
|
32
|
+
|
|
33
|
+
That last point is the sharp edge. The trigger aggregates
|
|
34
|
+
`WHERE product_id = ? AND type='review' AND status='approved'`, so anything stored as an
|
|
35
|
+
approved review moves the product's star average. **A staff reply is therefore stored as
|
|
36
|
+
`type='comment'` with a NULL rating**, carrying the parent's target. That is not a
|
|
37
|
+
workaround for convenience — `check_rating_only_for_review` is an exhaustive `OR` over
|
|
38
|
+
`('review','comment')`, so a `review` row with a NULL rating is rejected outright, and
|
|
39
|
+
adding a third enum value would violate the same constraint *and* hit PostgreSQL's rule
|
|
40
|
+
that an enum value cannot be used in the transaction that added it (i.e. in one
|
|
41
|
+
migration file). `cms_interactions_reply_check` pins the invariant in the schema.
|
|
42
|
+
|
|
43
|
+
## The private lane
|
|
44
|
+
|
|
45
|
+
`message_threads` is the spine; `thread_messages` holds the turns. Message *content* is
|
|
46
|
+
append-only, enforced by a trigger that binds the service role too — only the delivery
|
|
47
|
+
flags may change after insert. A consequence worth knowing: because the foreign key
|
|
48
|
+
cascades and the trigger blocks `DELETE`, **a thread cannot be deleted**. "Delete" in
|
|
49
|
+
the CMS means `status='closed'` plus a revoked token.
|
|
50
|
+
|
|
51
|
+
### The visitor's token
|
|
52
|
+
|
|
53
|
+
The only opaque credential this codebase issues to an anonymous person.
|
|
54
|
+
|
|
55
|
+
- 32 bytes of `crypto.randomBytes`, prefixed `nbt_`. Guessing is infeasible by
|
|
56
|
+
keyspace, which matters because the GET has no rate limit.
|
|
57
|
+
- Only `sha256` is stored, in `message_threads.token_hash` under a unique index. The
|
|
58
|
+
plaintext exists long enough to go into one email and is never written down.
|
|
59
|
+
- **Minted on the first admin reply, never at submission.** A store with a hundred
|
|
60
|
+
unanswered enquiries has zero live credentials.
|
|
61
|
+
- Rolling 90-day expiry, extended by each reply; revocable from the CMS.
|
|
62
|
+
|
|
63
|
+
`/thread/[token]` exchanges the token for an **HttpOnly cookie** and redirects to a
|
|
64
|
+
token-less `/thread`. This is the most important control in the feature: the app sends
|
|
65
|
+
`Referrer-Policy: strict-origin-when-cross-origin`, so a token left in the address bar
|
|
66
|
+
would travel in the `Referer` header of every same-origin navigation, and into history,
|
|
67
|
+
screenshots and proxy logs.
|
|
68
|
+
|
|
69
|
+
Every failure — expired, revoked, never existed — renders the *same* page. Distinguishing
|
|
70
|
+
them would make the route an oracle.
|
|
71
|
+
|
|
72
|
+
Reading the thread uses the service role **after** verifying the token in application
|
|
73
|
+
code. No RLS policy here authenticates an anonymous caller by token, and none was
|
|
74
|
+
invented; this matches how `/api/mcp` treats `mcp_access_tokens`.
|
|
75
|
+
|
|
76
|
+
## Contact forms and `form_key`
|
|
77
|
+
|
|
78
|
+
A form block used to store its destination in its own `content`. `FormBlockRenderer` is
|
|
79
|
+
a `"use client"` component that receives that content wholesale, so **the address was
|
|
80
|
+
serialized into the RSC payload of every page carrying a form** — published in the
|
|
81
|
+
markup.
|
|
82
|
+
|
|
83
|
+
Migration `00000000000027` moved every stored address into `form_endpoints` and left a
|
|
84
|
+
`form_key` behind: an opaque handle that grants nothing and is safe to serialize. The
|
|
85
|
+
migration walks three shapes, because a form nested in a section has no `blocks` row of
|
|
86
|
+
its own:
|
|
87
|
+
|
|
88
|
+
1. `blocks.content` where `block_type='form'`
|
|
89
|
+
2. `blocks.content` where `block_type='section'` (`column_blocks`, `slides`)
|
|
90
|
+
3. `content_drafts.blocks` / `product_drafts.blocks`
|
|
91
|
+
|
|
92
|
+
`form_endpoints.fields` is a **server-side snapshot** of the field manifest, so
|
|
93
|
+
notification emails and the inbox render labels the browser did not supply.
|
|
94
|
+
|
|
95
|
+
`BlockRenderer` and `SectionBlockRenderer` also strip `recipient_email` defensively
|
|
96
|
+
before handing content to the client — an import, a restored revision, or a fork that
|
|
97
|
+
has not migrated can still produce one.
|
|
98
|
+
|
|
99
|
+
## Delivery is best-effort; the row is the record
|
|
100
|
+
|
|
101
|
+
`sendEmail` **throws** when SMTP is unconfigured, and a store that has not finished one
|
|
102
|
+
piece of setup often has not finished the other. So every path writes first and notifies
|
|
103
|
+
afterwards, inside `after()`. A failed send sets `email_delivered = false` and records
|
|
104
|
+
`email_error`; the CMS shows "Not emailed" rather than pretending the owner was told.
|
|
105
|
+
|
|
106
|
+
The visitor is still shown success, because from their side it *was* one.
|
|
107
|
+
|
|
108
|
+
## Recipient resolution
|
|
109
|
+
|
|
110
|
+
Contact forms: per-form `form_endpoints.recipient_email` → `site_settings.forms_contact`
|
|
111
|
+
→ the seller-contact ladder (`lib/commerce/seller-contact.ts`: explicit store contact →
|
|
112
|
+
invoice email → privacy support email → oldest ADMIN's auth email). The sandbox
|
|
113
|
+
overrides everything.
|
|
114
|
+
|
|
115
|
+
**The address is never sent to the browser.** The form posts a `form_key`; the server
|
|
116
|
+
resolves the destination.
|
|
117
|
+
|
|
118
|
+
## The reply link and `NEXT_PUBLIC_URL`
|
|
119
|
+
|
|
120
|
+
`sendThreadNotice` refuses to send when the site URL was never configured, but happily
|
|
121
|
+
sends a local URL you chose deliberately.
|
|
122
|
+
|
|
123
|
+
`resolveSiteUrl()` falls back to `http://localhost:3000` when nothing is set, and a link
|
|
124
|
+
to that is dead for anyone not sitting at the machine. What must never happen is the code
|
|
125
|
+
*inventing* a localhost link because nothing was configured and mailing it to a customer.
|
|
126
|
+
A deliberate local URL is different: that is how you test the round trip, and it works.
|
|
127
|
+
|
|
128
|
+
So: honour an explicit choice, refuse an accidental default.
|
|
129
|
+
|
|
130
|
+
- Testing locally: set `NEXT_PUBLIC_URL=http://localhost:3000` (or your dev port). The
|
|
131
|
+
link is sent and works on that machine; a warning is logged saying who can open it.
|
|
132
|
+
- Production: set it to the public site URL. Vercel's project URL is picked up
|
|
133
|
+
automatically.
|
|
134
|
+
- Unset: the notice is refused and the reason is recorded on the message.
|
|
135
|
+
|
|
136
|
+
> An earlier version of this document blamed a `localhost` link for a quarantined
|
|
137
|
+
> message. That was wrong — a probe carrying exactly such a link was delivered normally.
|
|
138
|
+
> The real cause is below.
|
|
139
|
+
|
|
140
|
+
## Mail threading: two conversations, two roots
|
|
141
|
+
|
|
142
|
+
Outbound mail carries `In-Reply-To` and `References` pointing at a stable synthetic root,
|
|
143
|
+
because a `Re:` subject with no threading headers is itself a forged-reply heuristic, and
|
|
144
|
+
one consistent id per conversation groups the exchange in the recipient's client.
|
|
145
|
+
|
|
146
|
+
**The owner's notifications and the visitor's notices use DIFFERENT roots**, and that
|
|
147
|
+
separation is load-bearing:
|
|
148
|
+
|
|
149
|
+
- owner: `<nb-thread-{id}-admin@{from-domain}>`
|
|
150
|
+
- visitor: `<nb-thread-{id}-visitor@{from-domain}>`
|
|
151
|
+
|
|
152
|
+
They are two different exchanges with two different people. Sharing one root makes a mail
|
|
153
|
+
client fold them into a single conversation — and when an operator tests with their own
|
|
154
|
+
address as both the admin *and* the enquiring visitor (the obvious way to try the
|
|
155
|
+
feature), the reply is delivered, accepted, and then collapsed under the notification
|
|
156
|
+
they already read. It looks precisely like the email never arrived.
|
|
157
|
+
|
|
158
|
+
That symptom is a same-mailbox testing artifact, not a production fault: in real use the
|
|
159
|
+
customer and the shop owner are different mailboxes. But the shared root was a genuine
|
|
160
|
+
modelling error, and it is fixed.
|
|
161
|
+
|
|
162
|
+
## Where a form's mail goes
|
|
163
|
+
|
|
164
|
+
One rule, three rungs:
|
|
165
|
+
|
|
166
|
+
1. The form's own `form_endpoints.recipient_email`, if someone set one.
|
|
167
|
+
2. The site contact address from **CMS → Messages** (`site_settings.store_contact`).
|
|
168
|
+
3. The **first admin account's** own login address.
|
|
169
|
+
|
|
170
|
+
**A form has no address of its own by default**, and that is the intended state — since
|
|
171
|
+
the messaging system arrived, an operator does not need to think about per-form routing
|
|
172
|
+
at all. Submissions are stored as threads and answered in the CMS; the notification
|
|
173
|
+
address is one site-wide setting. A per-form address exists only to route one particular
|
|
174
|
+
form elsewhere — a careers form to HR, say.
|
|
175
|
+
|
|
176
|
+
Because rung 3 always resolves on a provisioned install, a fresh site reaches a real
|
|
177
|
+
human without configuring anything.
|
|
178
|
+
|
|
179
|
+
The sandbox overrides all of it: `resolveFormRecipient` returns `SANDBOX_CONTACT_EMAIL`
|
|
180
|
+
when `NEXT_PUBLIC_IS_SANDBOX` is set, so the hosted demo routes to the operator's inbox
|
|
181
|
+
without storing an address anywhere.
|
|
182
|
+
|
|
183
|
+
### Placeholder addresses count as unset
|
|
184
|
+
|
|
185
|
+
The starter content used to ship a contact form addressed to `contact@example.com`, and
|
|
186
|
+
migration 27 faithfully carried that into `form_endpoints`. Faithful was wrong:
|
|
187
|
+
`example.com` is reserved by RFC 2606 so it can never be registered, which makes the
|
|
188
|
+
address *guaranteed* undeliverable while looking like a real setting to every layer
|
|
189
|
+
downstream. The visitor is thanked, the relay accepts, nobody is notified, nothing errors.
|
|
190
|
+
An install ran that way without knowing.
|
|
191
|
+
|
|
192
|
+
Migration 29 clears those to NULL, so they fall through to the ladder above.
|
|
193
|
+
`lib/email/placeholder-address.ts` also treats the reserved domains (`example.com/.org/
|
|
194
|
+
.net/.edu` and the `.example`, `.invalid`, `.test`, `.localhost`, `.local` suffixes) as
|
|
195
|
+
unset at runtime, and `lib/cms/contact-reminder.ts` raises an ADMIN banner if one
|
|
196
|
+
reappears — via an import, a restored revision, or someone typing it.
|
|
197
|
+
|
|
198
|
+
The banner stays quiet when a form simply has no address, because that is now the correct
|
|
199
|
+
configuration rather than an omission.
|
|
200
|
+
|
|
201
|
+
## Subjects are per-conversation, and why that matters
|
|
202
|
+
|
|
203
|
+
| | contact form | product enquiry |
|
|
204
|
+
|---|---|---|
|
|
205
|
+
| **owner** | `Nicolas sent you a message [NRH-VWQOLB]` | `Nicolas asked about Brass Kettle [NRH-EMISCX]` |
|
|
206
|
+
| **visitor** | `New Roots Herbal replied to your message [NRH-VWQOLB]` | `New Roots Herbal replied about Brass Kettle [NRH-EMISCX]` |
|
|
207
|
+
|
|
208
|
+
`threadReference(threadId, siteName)` folds the first 32 bits of the thread id into six
|
|
209
|
+
base36 characters and prefixes the site's initials — about 2.2 billion references, read as
|
|
210
|
+
a ticket number rather than a hex dump.
|
|
211
|
+
|
|
212
|
+
**There is no `Re:` anywhere.** The visitor's message was never an email, so a reply
|
|
213
|
+
prefix is both a spam heuristic and a small lie; naming the site is what actually tells
|
|
214
|
+
them who is writing. The `In-Reply-To` / `References` headers still group each
|
|
215
|
+
conversation properly.
|
|
216
|
+
|
|
217
|
+
The reference is not decoration. **Exchange derives its ConversationTopic from the
|
|
218
|
+
subject, not from References**, so separating the References headers — necessary, and
|
|
219
|
+
done — was not sufficient. With a constant subject, every enquiry a site ever receives
|
|
220
|
+
collapses into one Outlook conversation, and anything applied to that conversation applies
|
|
221
|
+
to all of them: a rule, a filter, or Ignore Conversation.
|
|
222
|
+
|
|
223
|
+
A real install hit exactly this. Replies were accepted by the relay, accepted by Microsoft
|
|
224
|
+
with `250 Queued mail for delivery`, and routed straight to Deleted Items, because a
|
|
225
|
+
"Contact form" conversation had been ignored at some point. Nothing anywhere reported a
|
|
226
|
+
fault. It was isolated by sending two messages with byte-identical bodies and different
|
|
227
|
+
subjects: the unique subject arrived, `Re: Contact form` did not. When mail "doesn't
|
|
228
|
+
arrive", that A/B is the first thing to run — not a theory.
|
|
229
|
+
|
|
230
|
+
## Deliverability: accepted is not delivered
|
|
231
|
+
|
|
232
|
+
A reply can be accepted by the relay, accepted by the recipient's server with
|
|
233
|
+
`250 Queued mail for delivery`, appear in the inbox — and then be **removed from the
|
|
234
|
+
mailbox minutes later**. Microsoft calls this ZAP (Zero-hour Auto Purge): threat intel
|
|
235
|
+
updates after delivery and the message is retracted. There is no bounce and no SMTP
|
|
236
|
+
error, so `email_delivered = true` is accurate and every log upstream reports success.
|
|
237
|
+
|
|
238
|
+
**This is largely outside the application's control.** A strict corporate tenant can
|
|
239
|
+
purge a transactional message on its overall shape — a reply-styled body carrying a
|
|
240
|
+
quoted excerpt and a link to an opaque tokenised URL — and no rendering tested reliably
|
|
241
|
+
survived one such tenant. An intermediate result suggesting that showing the destination
|
|
242
|
+
URL was the deciding factor did not hold up against the real notice.
|
|
243
|
+
|
|
244
|
+
What the application does do:
|
|
245
|
+
|
|
246
|
+
**1. No secrecy language.** The message used to end "This link is personal to you —
|
|
247
|
+
please don't forward it." A secret one-off link, a prominent button, and an instruction
|
|
248
|
+
not to share it is a near-literal phishing template. The link's security comes from the
|
|
249
|
+
token rotating on every reply, not from asking the recipient to keep a secret.
|
|
250
|
+
|
|
251
|
+
**2. The destination is visible.** The call-to-action is a button, with the URL also
|
|
252
|
+
printed as plain text beneath it. Good practice regardless, and it survives clients that
|
|
253
|
+
strip styling.
|
|
254
|
+
|
|
255
|
+
**3. There is a manual channel.** `createVisitorLink` mints a link and hands it to the
|
|
256
|
+
admin without sending anything ("Copy visitor link" in the thread pane). When a
|
|
257
|
+
recipient's filtering removes the mail, the conversation is still intact and the link is
|
|
258
|
+
still obtainable — send it by whatever works.
|
|
259
|
+
|
|
260
|
+
### Diagnosing it
|
|
261
|
+
|
|
262
|
+
Send messages with **byte-identical bodies** differing in exactly one variable, and check
|
|
263
|
+
the recipient's Deleted Items as well as the inbox. Reasoning from correlation produced
|
|
264
|
+
five wrong answers here — a `localhost` link, spam filtering in general, a shared thread
|
|
265
|
+
root, the subject line, and the secrecy phrase — each of which fitted the evidence and
|
|
266
|
+
was killed by the next test.
|
|
267
|
+
|
|
268
|
+
The send path logs what actually went out:
|
|
269
|
+
|
|
270
|
+
```
|
|
271
|
+
[messages] Reply on thread <id> handed to SMTP — to="…" replyTo="…" subject="…" link=tokenised
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Start there. **Accepted by SMTP is not arrived, and arrived is not still there.** When a
|
|
275
|
+
tenant is purging on shape, testing against a different provider (a personal Gmail, say)
|
|
276
|
+
separates "the app is broken" from "this mailbox rejects this class of mail".
|
|
277
|
+
|
|
278
|
+
## Rate limiting
|
|
279
|
+
|
|
280
|
+
There is no shared rate limiter in this repo. Both public write paths throttle by
|
|
281
|
+
counting recent rows for a masked IP, and both **fail closed**: an absent or unparseable
|
|
282
|
+
`X-Forwarded-For` buckets under the literal `'unknown'` rather than skipping the check.
|
|
283
|
+
Trusted platform headers (`x-vercel-forwarded-for`, `x-real-ip`) are preferred over
|
|
284
|
+
`x-forwarded-for`, whose leftmost entry the client can write.
|
|
285
|
+
|
|
286
|
+
## Files
|
|
287
|
+
|
|
288
|
+
- `libs/db/src/supabase/migrations/00000000000027_message_threads.sql` — private lane, `form_endpoints`, the form-block data migration
|
|
289
|
+
- `libs/db/src/supabase/migrations/00000000000028_interaction_replies.sql` — `parent_id`, the reply CHECK, and the indexes `cms_interactions` never had
|
|
290
|
+
- `apps/nextblock/lib/messages/thread-token.ts` (+ `.test.ts`) — mint, parse, verify
|
|
291
|
+
- `apps/nextblock/lib/messages/threads.ts` — thread creation, recipient resolution, both notification emails
|
|
292
|
+
- `apps/nextblock/app/thread/**` — the visitor's page and the token-exchange route
|
|
293
|
+
- `apps/nextblock/app/actions/threadActions.ts` — visitor replies
|
|
294
|
+
- `apps/nextblock/app/cms/messages/**` — the inbox, its loader and its admin actions
|
|
295
|
+
- `apps/nextblock/app/actions/interactions.ts` — `replyToInteraction`, the public lane
|
|
296
|
+
- `apps/nextblock/components/StaffReplies.tsx` — nested public replies on the storefront
|
|
297
|
+
|
|
298
|
+
`/cms/inquiries` and `/cms/interactions` are now redirects into the inbox; both were
|
|
299
|
+
linked from notification emails and bookmarks, so neither was deleted.
|
|
300
|
+
|
|
301
|
+
## Known gaps
|
|
302
|
+
|
|
303
|
+
- Interactions have **no per-user read marker** anywhere in the schema, so the nav badge
|
|
304
|
+
counts "pending moderation". Two admins working the queue see the same number.
|
|
305
|
+
- Public replies do not email the review/comment author. They have a reachable address,
|
|
306
|
+
but that would be the first time the platform emails a registered user about content
|
|
307
|
+
activity, with no preference to opt out of.
|
|
308
|
+
- Visitors get no acknowledgement email at submission time — that would require minting
|
|
309
|
+
a token immediately, which is exactly the live-credential surface this design avoids.
|
|
@@ -1,41 +1,42 @@
|
|
|
1
|
-
# NextBlock CMS Documentation
|
|
2
|
-
|
|
3
|
-
This folder is the source-of-truth reference set for the NextBlock monorepo.
|
|
4
|
-
The numbered files are written from live code, routes, migrations, and shipped
|
|
5
|
-
library surfaces rather than historical planning notes.
|
|
6
|
-
|
|
7
|
-
## Start Here
|
|
8
|
-
|
|
9
|
-
- Product and architecture overview: [01-PROJECT-OVERVIEW.md](./01-PROJECT-OVERVIEW.md)
|
|
10
|
-
- Commerce capabilities: [02-ECOMMERCE-CAPABILITIES.md](./02-ECOMMERCE-CAPABILITIES.md)
|
|
11
|
-
- CMS editor and block system: [03-CMS-AND-EDITOR.md](./03-CMS-AND-EDITOR.md)
|
|
12
|
-
- Database, auth, and migrations: [04-DATABASE-AND-AUTH.md](./04-DATABASE-AND-AUTH.md)
|
|
13
|
-
- Contributor workflow and local operations: [05-DEVELOPER-GUIDE.md](./05-DEVELOPER-GUIDE.md)
|
|
14
|
-
- CLI and scaffolded project flow: [06-CLI-AND-SCAFFOLDING.md](./06-CLI-AND-SCAFFOLDING.md)
|
|
15
|
-
- Block SDK and extensibility surface: [07-BLOCK-SDK-AND-EXTENSIBILITY.md](./07-BLOCK-SDK-AND-EXTENSIBILITY.md)
|
|
16
|
-
- Cortex AI architecture: [08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md](./08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md)
|
|
17
|
-
- Live draft (visual editing) mode: [09-LIVE-DRAFT-MODE.md](./09-LIVE-DRAFT-MODE.md)
|
|
18
|
-
- Custom blocks (data-driven CRUD): [10-CUSTOM-BLOCKS.md](./10-CUSTOM-BLOCKS.md)
|
|
19
|
-
- Self-hosted local Docker stack: [11-SELF-HOSTED-DOCKER.md](./11-SELF-HOSTED-DOCKER.md)
|
|
20
|
-
- One-click cloud deploy (Deploy to Vercel): [12-VERCEL-DEPLOYMENT.md](./12-VERCEL-DEPLOYMENT.md)
|
|
21
|
-
- Updating an install (`npm run update`, upstream sync): [13-STAYING-UP-TO-DATE.md](./13-STAYING-UP-TO-DATE.md)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
1
|
+
# NextBlock CMS Documentation
|
|
2
|
+
|
|
3
|
+
This folder is the source-of-truth reference set for the NextBlock monorepo.
|
|
4
|
+
The numbered files are written from live code, routes, migrations, and shipped
|
|
5
|
+
library surfaces rather than historical planning notes.
|
|
6
|
+
|
|
7
|
+
## Start Here
|
|
8
|
+
|
|
9
|
+
- Product and architecture overview: [01-PROJECT-OVERVIEW.md](./01-PROJECT-OVERVIEW.md)
|
|
10
|
+
- Commerce capabilities: [02-ECOMMERCE-CAPABILITIES.md](./02-ECOMMERCE-CAPABILITIES.md)
|
|
11
|
+
- CMS editor and block system: [03-CMS-AND-EDITOR.md](./03-CMS-AND-EDITOR.md)
|
|
12
|
+
- Database, auth, and migrations: [04-DATABASE-AND-AUTH.md](./04-DATABASE-AND-AUTH.md)
|
|
13
|
+
- Contributor workflow and local operations: [05-DEVELOPER-GUIDE.md](./05-DEVELOPER-GUIDE.md)
|
|
14
|
+
- CLI and scaffolded project flow: [06-CLI-AND-SCAFFOLDING.md](./06-CLI-AND-SCAFFOLDING.md)
|
|
15
|
+
- Block SDK and extensibility surface: [07-BLOCK-SDK-AND-EXTENSIBILITY.md](./07-BLOCK-SDK-AND-EXTENSIBILITY.md)
|
|
16
|
+
- Cortex AI architecture: [08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md](./08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md)
|
|
17
|
+
- Live draft (visual editing) mode: [09-LIVE-DRAFT-MODE.md](./09-LIVE-DRAFT-MODE.md)
|
|
18
|
+
- Custom blocks (data-driven CRUD): [10-CUSTOM-BLOCKS.md](./10-CUSTOM-BLOCKS.md)
|
|
19
|
+
- Self-hosted local Docker stack: [11-SELF-HOSTED-DOCKER.md](./11-SELF-HOSTED-DOCKER.md)
|
|
20
|
+
- One-click cloud deploy (Deploy to Vercel): [12-VERCEL-DEPLOYMENT.md](./12-VERCEL-DEPLOYMENT.md)
|
|
21
|
+
- Updating an install (`npm run update`, upstream sync): [13-STAYING-UP-TO-DATE.md](./13-STAYING-UP-TO-DATE.md)
|
|
22
|
+
- Messages inbox (enquiries, contact forms, reviews, comments): [14-MESSAGES-INBOX.md](./14-MESSAGES-INBOX.md)
|
|
23
|
+
|
|
24
|
+
## Audience Guide
|
|
25
|
+
|
|
26
|
+
- New contributors: read `01`, then `04`, then `05`.
|
|
27
|
+
- Commerce work: read `02`, then `04`.
|
|
28
|
+
- Editor or page-builder work: read `03`, then `07`.
|
|
29
|
+
- Custom block work: read `03`, then `10`.
|
|
30
|
+
- AI / Cortex work: read `08`.
|
|
31
|
+
- CLI or template work: read `06`.
|
|
32
|
+
- Publishing the libraries / scaffold CLI: read `06`.
|
|
33
|
+
- Running everything locally without cloud accounts: read `11`.
|
|
34
|
+
- One-click cloud deploy and the browser setup wizard: read `12`.
|
|
35
|
+
- Updating an existing install, whichever way it was created: read `13`.
|
|
36
|
+
- AI agents: start with this index, then move directly to the subsystem file that
|
|
37
|
+
matches the task. Treat `apps/nextblock`, `libs/*`, and
|
|
38
|
+
`libs/db/src/supabase/migrations` as the final authority if a doc and code ever
|
|
39
|
+
disagree.
|
|
40
|
+
- AI agents touching migrations must also read the root `AGENTS.md` note:
|
|
41
|
+
production/shared database changes are append-only and non-destructive by
|
|
42
|
+
default.
|
|
Binary file
|
|
@@ -39,12 +39,26 @@ describe('resolveTextColor', () => {
|
|
|
39
39
|
expect(resolveTextColor(undefined)).toEqual({});
|
|
40
40
|
expect(resolveTextColor(null)).toEqual({});
|
|
41
41
|
expect(resolveTextColor('')).toEqual({});
|
|
42
|
-
expect(resolveTextColor('
|
|
42
|
+
expect(resolveTextColor('notacolour')).toEqual({});
|
|
43
43
|
expect(resolveTextColor('javascript:alert(1)')).toEqual({});
|
|
44
44
|
expect(resolveTextColor('url(https://evil.test/x.png)')).toEqual({});
|
|
45
|
+
// A valid name with anything appended is not a name — this is the injection case,
|
|
46
|
+
// and it must stay rejected even though `red` on its own is now accepted.
|
|
45
47
|
expect(resolveTextColor('red; background: url(x)')).toEqual({});
|
|
46
48
|
});
|
|
47
49
|
|
|
50
|
+
it('accepts CSS named colours, which seeded and AI-authored content actually uses', () => {
|
|
51
|
+
// The seeded contact-page heading is `textColor: "white"`. It matched neither a
|
|
52
|
+
// token nor the hex/rgb/hsl pattern, so it resolved to {} and the white-on-dark hero
|
|
53
|
+
// rendered dark-on-dark with nothing reporting a problem.
|
|
54
|
+
expect(resolveTextColor('white')).toEqual({ style: { color: 'white' } });
|
|
55
|
+
expect(resolveTextColor('Black')).toEqual({ style: { color: 'Black' } });
|
|
56
|
+
expect(resolveTextColor(' transparent ')).toEqual({ style: { color: 'transparent' } });
|
|
57
|
+
|
|
58
|
+
// Still distinguishable from a theme token, which resolves to a class instead.
|
|
59
|
+
expect(resolveTextColor('accent')).toEqual({ className: 'text-accent' });
|
|
60
|
+
});
|
|
61
|
+
|
|
48
62
|
it('never emits an interpolated class name', () => {
|
|
49
63
|
for (const token of TEXT_COLOR_TOKENS) {
|
|
50
64
|
const { className } = resolveTextColor(token);
|
|
@@ -73,7 +87,11 @@ describe('guards', () => {
|
|
|
73
87
|
expect(isTextColorToken('accent')).toBe(true);
|
|
74
88
|
expect(isTextColorToken('#FFF')).toBe(false);
|
|
75
89
|
expect(isCustomCssColor('#FFF')).toBe(true);
|
|
90
|
+
expect(isCustomCssColor('white')).toBe(true);
|
|
91
|
+
// A theme token is not a literal colour: the two share one field and are told apart
|
|
92
|
+
// only here, so this must keep answering false.
|
|
76
93
|
expect(isCustomCssColor('accent')).toBe(false);
|
|
94
|
+
expect(isCustomCssColor('notacolour')).toBe(false);
|
|
77
95
|
});
|
|
78
96
|
});
|
|
79
97
|
|
|
@@ -103,7 +121,9 @@ describe('HeadingBlockSchema.textColor', () => {
|
|
|
103
121
|
});
|
|
104
122
|
|
|
105
123
|
it('rejects values that are neither a token nor a colour', () => {
|
|
106
|
-
|
|
124
|
+
// 'red' was in this list only because named colours used to be rejected wholesale.
|
|
125
|
+
// It is a real colour and is now accepted; the genuinely invalid values stay here.
|
|
126
|
+
for (const bad of ['nonsense', 'url(x)', 'red; background: url(x)', '']) {
|
|
107
127
|
expect(HeadingBlockSchema.safeParse({ ...base, textColor: bad }).success).toBe(false);
|
|
108
128
|
}
|
|
109
129
|
});
|
|
@@ -82,12 +82,55 @@ export const TEXT_COLOR_TOKEN_OPTIONS = TEXT_COLOR_TOKENS.map((token) => ({
|
|
|
82
82
|
export const CSS_COLOR_PATTERN =
|
|
83
83
|
/^(#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|rgba?\([^)]+\)|hsla?\([^)]+\))$/i;
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* CSS named colours, which the picker never emits but hand-authored and AI-authored
|
|
87
|
+
* content does.
|
|
88
|
+
*
|
|
89
|
+
* The seeded contact page sets `textColor: "white"` on its heading. That is perfectly
|
|
90
|
+
* valid CSS, but it matched neither a theme token nor the literal pattern above, so
|
|
91
|
+
* `resolveTextColor` returned `{}` and the heading quietly inherited the dark default —
|
|
92
|
+
* a white-on-dark hero rendering dark-on-dark, with nothing anywhere reporting a problem.
|
|
93
|
+
*
|
|
94
|
+
* Spelled out as a set rather than folded into the pattern as `[a-z]+`, because
|
|
95
|
+
* `isCustomCssColor` has to keep answering false for a theme token like `accent`: the
|
|
96
|
+
* two are stored in the same field and told apart only by these predicates.
|
|
97
|
+
*/
|
|
98
|
+
const CSS_NAMED_COLORS = new Set(
|
|
99
|
+
(
|
|
100
|
+
'aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue ' +
|
|
101
|
+
'blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk ' +
|
|
102
|
+
'crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki ' +
|
|
103
|
+
'darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen ' +
|
|
104
|
+
'darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue ' +
|
|
105
|
+
'dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite ' +
|
|
106
|
+
'gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki ' +
|
|
107
|
+
'lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan ' +
|
|
108
|
+
'lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen ' +
|
|
109
|
+
'lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen ' +
|
|
110
|
+
'magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen ' +
|
|
111
|
+
'mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream ' +
|
|
112
|
+
'mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid ' +
|
|
113
|
+
'palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum ' +
|
|
114
|
+
'powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown ' +
|
|
115
|
+
'seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen ' +
|
|
116
|
+
'steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow ' +
|
|
117
|
+
'yellowgreen transparent currentcolor'
|
|
118
|
+
).split(' ')
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
export function isCssNamedColor(value: unknown): value is string {
|
|
122
|
+
return typeof value === 'string' && CSS_NAMED_COLORS.has(value.trim().toLowerCase());
|
|
123
|
+
}
|
|
124
|
+
|
|
85
125
|
export function isTextColorToken(value: unknown): value is TextColorToken {
|
|
86
126
|
return typeof value === 'string' && (TEXT_COLOR_TOKENS as readonly string[]).includes(value);
|
|
87
127
|
}
|
|
88
128
|
|
|
89
129
|
export function isCustomCssColor(value: unknown): value is string {
|
|
90
|
-
return
|
|
130
|
+
return (
|
|
131
|
+
(typeof value === 'string' && CSS_COLOR_PATTERN.test(value.trim())) ||
|
|
132
|
+
isCssNamedColor(value)
|
|
133
|
+
);
|
|
91
134
|
}
|
|
92
135
|
|
|
93
136
|
export interface ResolvedTextColor {
|