@skuio/storefront 0.6.1 → 0.7.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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/store/errors.ts","../src/store/crypto.ts","../src/store/ratelimit.ts","../src/store/context.ts","../src/store/connections.ts"],"sourcesContent":["export type StoreErrorCode = 'not_found' | 'validation_failed' | 'conflict' | 'unavailable' | 'rate_limited' | 'invalid_grant'\n\n/** A domain error the API maps straight to a status code. */\nexport class StoreError extends Error {\n readonly code: StoreErrorCode\n readonly details: Record<string, string> | null\n\n constructor(code: StoreErrorCode, message: string, details: Record<string, string> | null = null) {\n super(message)\n this.name = 'StoreError'\n this.code = code\n this.details = details\n }\n\n get status(): number {\n switch (this.code) {\n case 'not_found':\n return 404\n case 'validation_failed':\n return 422\n case 'conflict':\n return 409\n case 'unavailable':\n return 503\n case 'rate_limited':\n return 429\n case 'invalid_grant':\n return 400\n }\n }\n}\n\nexport const notFound = (what: string) => new StoreError('not_found', `${what} not found`)\nexport const invalid = (message: string, details: Record<string, string> | null = null) =>\n new StoreError('validation_failed', message, details)\nexport const conflict = (message: string) => new StoreError('conflict', message)\n","/**\n * Token and hashing helpers on WebCrypto, which both Workers and Node 20+ expose\n * as the global `crypto`.\n */\nconst encoder = new TextEncoder()\n\nconst ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'\n\n/** Random id with a type prefix: ord_…, cus_…, ful_… */\nexport function newId(prefix: string, length = 20): string {\n const bytes = new Uint8Array(length)\n crypto.getRandomValues(bytes)\n let out = ''\n for (const b of bytes) out += ALPHABET[b % ALPHABET.length]\n return `${prefix}_${out}`\n}\n\n/** Random secret token, hex. */\nexport function randomToken(bytes = 32): string {\n const buf = new Uint8Array(bytes)\n crypto.getRandomValues(buf)\n return toHex(buf)\n}\n\n/** Random secret as URL-safe base64 (no padding); 32 bytes → 43 characters. */\nexport function randomBase64Url(bytes = 32): string {\n const buf = new Uint8Array(bytes)\n crypto.getRandomValues(buf)\n let bin = ''\n for (const b of buf) bin += String.fromCharCode(b)\n return btoa(bin).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nexport function toHex(buf: ArrayBuffer | Uint8Array): string {\n const view = buf instanceof Uint8Array ? buf : new Uint8Array(buf)\n let out = ''\n for (const b of view) out += b.toString(16).padStart(2, '0')\n return out\n}\n\nexport async function sha256Hex(input: string): Promise<string> {\n return toHex(await crypto.subtle.digest('SHA-256', encoder.encode(input)))\n}\n\n/**\n * Hash a bearer token for storage. With a secret the hash is an HMAC, so a\n * copy of the database alone cannot be used to forge a session.\n */\nexport async function hashToken(token: string, secret?: string): Promise<string> {\n if (!secret) return sha256Hex(token)\n const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])\n return toHex(await crypto.subtle.sign('HMAC', key, encoder.encode(token)))\n}\n\n/** Compare two secrets without leaking where they differ. Hashes first so lengths never leak either. */\nexport async function secretsEqual(a: string, b: string): Promise<boolean> {\n const [ha, hb] = await Promise.all([sha256Hex(a), sha256Hex(b)])\n let diff = 0\n for (let i = 0; i < ha.length; i++) diff |= ha.charCodeAt(i) ^ hb.charCodeAt(i)\n return diff === 0\n}\n","import type { DbStatement } from '../db/driver'\nimport type { StoreContext } from './context'\nimport { StoreError } from './errors'\n\nexport interface RateLimitPolicy {\n /** Failures allowed inside one window before the key locks. */\n maxFailures: number\n windowMinutes: number\n lockMinutes: number\n}\n\n/**\n * A failure counter in the database, keyed by whatever the caller wants to\n * throttle (sign-ins per email + address, code exchanges per address). Lives\n * in D1 rather than isolate memory so every Worker instance sees the same\n * count. Successes clear the key; nothing is counted for them.\n */\nexport class RateLimiter {\n constructor(private readonly ctx: StoreContext, private readonly policy: RateLimitPolicy) {}\n\n /** Throws `rate_limited` (429) while the key is locked. */\n async assertAllowed(key: string, message: string): Promise<void> {\n const now = this.ctx.now()\n const row = await this.ctx.db.prepare(`SELECT locked_until FROM auth_attempts WHERE key = ?`).bind(key).first<{ locked_until: string | null }>()\n if (row?.locked_until && row.locked_until > now) throw new StoreError('rate_limited', message)\n }\n\n async recordFailure(key: string): Promise<void> {\n const now = this.ctx.now()\n const nowMs = new Date(now).getTime()\n const row = await this.ctx.db\n .prepare(`SELECT failures, window_started_at, locked_until FROM auth_attempts WHERE key = ?`)\n .bind(key)\n .first<{ failures: number; window_started_at: string; locked_until: string | null }>()\n const inWindow = row && nowMs - new Date(row.window_started_at).getTime() < this.policy.windowMinutes * 60_000\n const failures = inWindow ? row!.failures + 1 : 1\n const windowStart = inWindow ? row!.window_started_at : now\n const lockedUntil = failures >= this.policy.maxFailures ? new Date(nowMs + this.policy.lockMinutes * 60_000).toISOString() : null\n await this.ctx.db\n .prepare(\n `INSERT INTO auth_attempts (key, failures, window_started_at, locked_until) VALUES (?, ?, ?, ?)\n ON CONFLICT (key) DO UPDATE SET failures = excluded.failures, window_started_at = excluded.window_started_at, locked_until = excluded.locked_until`\n )\n .bind(key, failures, windowStart, lockedUntil)\n .run()\n }\n\n clearStatement(key: string): DbStatement {\n return this.ctx.db.prepare(`DELETE FROM auth_attempts WHERE key = ?`).bind(key)\n }\n\n /** Rows whose window and lock have both passed carry no information; drop them. */\n pruneStatement(): DbStatement {\n const cutoff = new Date(new Date(this.ctx.now()).getTime() - Math.max(this.policy.windowMinutes, this.policy.lockMinutes) * 60_000).toISOString()\n return this.ctx.db.prepare(`DELETE FROM auth_attempts WHERE window_started_at <= ? AND (locked_until IS NULL OR locked_until <= ?)`).bind(cutoff, this.ctx.now())\n }\n}\n","import type { StorefrontConfig } from '../config'\nimport type { Database } from '../db/driver'\nimport type { MailAdapter } from '../mail/types'\n\n/** Environment the store reads. Every key is optional; missing ones degrade to safe defaults. */\nexport interface StoreEnv {\n /** Absolute public origin used in emails and return URLs. Defaults to config.url. */\n PUBLIC_SITE_URL?: string\n /** HMAC key for session and magic-link hashes. */\n SESSION_SECRET?: string\n /** Merchant address that hears about every paid order. */\n ORDER_NOTIFICATION_EMAIL?: string\n /** Merchant address that hears about quote requests and answers; falls back to ORDER_NOTIFICATION_EMAIL. */\n QUOTE_NOTIFICATION_EMAIL?: string\n [key: string]: string | undefined\n}\n\nexport interface StoreContext {\n db: Database\n config: StorefrontConfig\n env: StoreEnv\n mail: MailAdapter\n /** ISO timestamp, injectable for tests. */\n now(): string\n siteUrl: string\n log(message: string, data?: unknown): void\n}\n\n/** Split an IN (...) list so no statement exceeds D1's bound-parameter limit. */\nexport function chunk<T>(items: T[], size = 80): T[][] {\n const out: T[][] = []\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))\n return out\n}\n\nexport const placeholders = (n: number) => Array.from({ length: n }, () => '?').join(',')\n\nexport function parseJson<T>(raw: unknown, fallback: T): T {\n if (typeof raw !== 'string' || !raw) return fallback\n try {\n return JSON.parse(raw) as T\n } catch {\n return fallback\n }\n}\n","import { parseJson, type StoreContext } from './context'\nimport { newId, randomBase64Url, randomToken, sha256Hex } from './crypto'\nimport { StoreError } from './errors'\nimport { RateLimiter } from './ratelimit'\n\nexport const CONNECT_CODE_MINUTES = 10\nexport const API_KEY_PREFIX = 'sk_store_'\n/**\n * Operations systems the store can name on its approval screen. Any other\n * client id may connect too — an ERP, an OMS, a script — because nothing is\n * granted until the owner approves the request in /admin/connect, which shows\n * the client, the account and the exact host the browser returns to.\n */\nexport const KNOWN_CONNECT_CLIENTS: Readonly<Record<string, string>> = { skuio: 'SKU.io' }\n/** @deprecated Every well-formed client id is accepted; see KNOWN_CONNECT_CLIENTS. */\nexport const CONNECT_CLIENT_IDS = Object.keys(KNOWN_CONNECT_CLIENTS) as readonly string[]\nconst CLIENT_ID = /^[a-z0-9][a-z0-9._-]{1,62}$/\n\n/** The display name for a client id: a known system's name, else the id itself. */\nexport function connectClientName(clientId: string): string {\n return KNOWN_CONNECT_CLIENTS[clientId] ?? clientId\n}\nexport const CONNECT_SCOPES = ['orders', 'payments', 'fulfillments', 'inventory', 'catalog', 'quotes'] as const\nexport type ConnectScope = (typeof CONNECT_SCOPES)[number]\n\nexport const SCOPE_DESCRIPTIONS: Record<ConnectScope, string> = {\n orders: 'Read orders, stamp its own order number on them and cancel them',\n payments: 'Mark orders paid, refunded or failed (for payments taken outside this store)',\n fulfillments: 'Push shipments and tracking to orders',\n inventory: 'Update available stock',\n catalog: 'Read and update products and categories',\n quotes: 'Read quote requests and send buyers priced quotes they can accept as orders',\n}\n\nexport interface Connection {\n id: string\n name: string\n tenant: string | null\n redirectHost: string | null\n scopes: ConnectScope[]\n keyPrefix: string | null\n createdByOwnerId: string | null\n createdAt: string\n issuedAt: string | null\n lastUsedAt: string | null\n revokedAt: string | null\n}\n\nexport interface AuthorizeParams {\n client_id?: string | null\n redirect_uri?: string | null\n state?: string | null\n tenant?: string | null\n scope?: string | null\n}\n\n/** A validated connect request, ready to show the owner. */\nexport interface AuthorizeRequest {\n clientId: string\n /** Display name: \"SKU.io\" for skuio, else the client id. */\n clientName: string\n redirectUri: string\n redirectHost: string\n state: string\n tenant: string\n scopes: ConnectScope[]\n}\n\nexport interface IssuedKey {\n apiKey: string\n connection: Connection\n}\n\ninterface ConnectionRow {\n id: string\n name: string\n tenant: string | null\n redirect_host: string | null\n scopes_json: string\n key_hash: string | null\n key_prefix: string | null\n created_by_owner_id: string | null\n created_at: string\n issued_at: string | null\n last_used_at: string | null\n revoked_at: string | null\n}\n\nconst toConnection = (r: ConnectionRow): Connection => ({\n id: r.id,\n name: r.name,\n tenant: r.tenant,\n redirectHost: r.redirect_host,\n scopes: parseJson<string[]>(r.scopes_json, []).filter((s): s is ConnectScope => (CONNECT_SCOPES as readonly string[]).includes(s)),\n keyPrefix: r.key_prefix,\n createdByOwnerId: r.created_by_owner_id,\n createdAt: r.created_at,\n issuedAt: r.issued_at,\n lastUsedAt: r.last_used_at,\n revokedAt: r.revoked_at,\n})\n\n/** http is allowed only where TLS cannot exist: a developer's machine. */\nexport function isLocalHost(hostname: string): boolean {\n const h = hostname.toLowerCase()\n return h === 'localhost' || h === '127.0.0.1' || h === '[::1]' || h.endsWith('.localhost') || h.endsWith('.test')\n}\n\n/**\n * Consumers the owner approved. The store is the authorization server: an\n * owner approves a request in the browser, the consumer trades the resulting\n * one-time code for a per-connection API key, and only the key's SHA-256 is\n * ever stored. Revoking a connection turns its key into a 401.\n */\nexport class ConnectionsService {\n private readonly limiter: RateLimiter\n\n constructor(private readonly ctx: StoreContext) {\n this.limiter = new RateLimiter(ctx, { maxFailures: 20, windowMinutes: 15, lockMinutes: 15 })\n }\n\n /** Validate the query of a connect request. Throws `validation_failed` with a field per problem. */\n validateAuthorize(params: AuthorizeParams): AuthorizeRequest {\n const details: Record<string, string> = {}\n const clientId = (params.client_id ?? '').trim()\n if (!CLIENT_ID.test(clientId)) details.client_id = 'must be 2-63 lowercase letters, digits, dots, dashes or underscores'\n const state = (params.state ?? '').trim()\n if (!state) details.state = 'is required'\n else if (state.length > 1024) details.state = 'must be at most 1024 characters'\n const tenant = (params.tenant ?? '').trim()\n if (!tenant) details.tenant = 'is required'\n else if (tenant.length > 120) details.tenant = 'must be at most 120 characters'\n\n let redirect: URL | null = null\n const raw = (params.redirect_uri ?? '').trim()\n if (!raw) details.redirect_uri = 'is required'\n else {\n try {\n redirect = new URL(raw)\n } catch {\n details.redirect_uri = 'must be an absolute URL'\n }\n }\n if (redirect) {\n if (redirect.hash) details.redirect_uri = 'must not carry a fragment'\n else if (redirect.protocol !== 'https:' && !(redirect.protocol === 'http:' && isLocalHost(redirect.hostname))) details.redirect_uri = 'must use https'\n else if (redirect.username || redirect.password) details.redirect_uri = 'must not carry credentials'\n }\n\n const scopeRaw = (params.scope ?? '').split(/[\\s,]+/).map((s) => s.trim()).filter(Boolean)\n const scopes = [...new Set(scopeRaw)] as string[]\n const unknown = scopes.filter((s) => !(CONNECT_SCOPES as readonly string[]).includes(s))\n if (!scopes.length) details.scope = 'is required'\n else if (unknown.length) details.scope = `unknown scope: ${unknown.join(', ')}`\n\n if (Object.keys(details).length) throw new StoreError('validation_failed', 'Invalid connect request', details)\n return { clientId, clientName: connectClientName(clientId), redirectUri: redirect!.href, redirectHost: redirect!.host, state, tenant, scopes: scopes as ConnectScope[] }\n }\n\n /** The owner said yes: create the connection and a one-time code. Returns the URL to send the browser to. */\n async approve(request: AuthorizeRequest, ownerId: string | null): Promise<{ connection: Connection; code: string; redirectTo: string }> {\n const now = this.ctx.now()\n const id = newId('con')\n const code = randomToken(32)\n const expiresAt = new Date(new Date(now).getTime() + CONNECT_CODE_MINUTES * 60_000).toISOString()\n const name = `${request.tenant} on ${request.clientName}`\n await this.ctx.db.batch([\n this.ctx.db\n .prepare(`INSERT INTO connections (id, name, tenant, redirect_host, scopes_json, created_by_owner_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`)\n .bind(id, name, request.tenant, request.redirectHost, JSON.stringify(request.scopes), ownerId, now),\n this.ctx.db\n .prepare(`INSERT INTO connect_codes (code_hash, connection_id, redirect_uri, expires_at, created_at) VALUES (?, ?, ?, ?, ?)`)\n .bind(await sha256Hex(code), id, request.redirectUri, expiresAt, now),\n ])\n const connection = (await this.byId(id))!\n return { connection, code, redirectTo: withQuery(request.redirectUri, { code, state: request.state }) }\n }\n\n /** The owner said no. */\n denyUrl(request: AuthorizeRequest): string {\n return withQuery(request.redirectUri, { error: 'access_denied', state: request.state })\n }\n\n /**\n * Trade a code for the connection's key. Exactly once: the code is marked used\n * in the same batch that stores the key hash, so a replay finds it used.\n * `address` keys the failure counter.\n */\n async exchange(input: { code?: unknown; redirect_uri?: unknown }, address: string): Promise<IssuedKey> {\n const limitKey = `token:${address}`\n await this.limiter.assertAllowed(limitKey, 'Too many failed exchanges. Try again in a few minutes.')\n const fail = async (message: string) => {\n await this.limiter.recordFailure(limitKey)\n return new StoreError('invalid_grant', message)\n }\n const code = typeof input.code === 'string' ? input.code.trim() : ''\n const redirectUri = typeof input.redirect_uri === 'string' ? input.redirect_uri.trim() : ''\n if (!code || !redirectUri) throw await fail('code and redirect_uri are required')\n const now = this.ctx.now()\n const codeHash = await sha256Hex(code)\n const row = await this.ctx.db\n .prepare(`SELECT connection_id, redirect_uri, expires_at, used_at FROM connect_codes WHERE code_hash = ?`)\n .bind(codeHash)\n .first<{ connection_id: string; redirect_uri: string; expires_at: string; used_at: string | null }>()\n if (!row || row.used_at || row.expires_at <= now) throw await fail('The code is unknown, used or expired')\n if (row.redirect_uri !== redirectUri) throw await fail('redirect_uri does not match the one the code was issued for')\n const existing = await this.byId(row.connection_id)\n if (!existing || existing.revokedAt || existing.keyPrefix) throw await fail('The code is unknown, used or expired')\n\n const apiKey = `${API_KEY_PREFIX}${randomBase64Url(32)}`\n await this.ctx.db.batch([\n this.ctx.db.prepare(`UPDATE connect_codes SET used_at = ? WHERE code_hash = ? AND used_at IS NULL`).bind(now, codeHash),\n this.ctx.db\n .prepare(`UPDATE connections SET key_hash = ?, key_prefix = ?, issued_at = ? WHERE id = ? AND key_hash IS NULL`)\n .bind(await sha256Hex(apiKey), apiKey.slice(0, API_KEY_PREFIX.length + 8), now, row.connection_id),\n this.limiter.clearStatement(limitKey),\n ])\n const connection = (await this.byId(row.connection_id))!\n if (!connection.keyPrefix) throw await fail('The code is unknown, used or expired')\n return { apiKey, connection }\n }\n\n /** The live connection behind a bearer key, or null. Bumps last_used_at at most once a minute. */\n async resolveKey(bearer: string): Promise<Connection | null> {\n if (!bearer.startsWith(API_KEY_PREFIX)) return null\n const row = await this.ctx.db.prepare(`SELECT * FROM connections WHERE key_hash = ?`).bind(await sha256Hex(bearer)).first<ConnectionRow>()\n if (!row || row.revoked_at) return null\n const now = this.ctx.now()\n if (!row.last_used_at || new Date(now).getTime() - new Date(row.last_used_at).getTime() > 60_000) {\n await this.ctx.db.prepare(`UPDATE connections SET last_used_at = ? WHERE id = ?`).bind(now, row.id).run()\n row.last_used_at = now\n }\n return toConnection(row)\n }\n\n async byId(id: string): Promise<Connection | null> {\n const row = await this.ctx.db.prepare(`SELECT * FROM connections WHERE id = ?`).bind(id).first<ConnectionRow>()\n return row ? toConnection(row) : null\n }\n\n /** Every connection that was issued a key, newest first. Pending (never exchanged) ones are not listed. */\n async list(): Promise<Connection[]> {\n const { results } = await this.ctx.db.prepare(`SELECT * FROM connections WHERE key_hash IS NOT NULL ORDER BY created_at DESC`).all<ConnectionRow>()\n return results.map(toConnection)\n }\n\n async revoke(id: string): Promise<boolean> {\n const result = await this.ctx.db.prepare(`UPDATE connections SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`).bind(this.ctx.now(), id).run()\n return (result.meta?.changes ?? 0) > 0\n }\n\n /** Housekeeping: expired codes, connections whose code was never exchanged, stale limiter rows. */\n async prune(): Promise<void> {\n const now = this.ctx.now()\n await this.ctx.db.batch([\n this.ctx.db.prepare(`DELETE FROM connect_codes WHERE expires_at <= ? OR used_at IS NOT NULL`).bind(now),\n this.ctx.db.prepare(`DELETE FROM connections WHERE key_hash IS NULL AND created_at <= ?`).bind(new Date(new Date(now).getTime() - CONNECT_CODE_MINUTES * 60_000).toISOString()),\n this.limiter.pruneStatement(),\n ])\n }\n}\n\nfunction withQuery(base: string, params: Record<string, string>): string {\n const url = new URL(base)\n for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)\n return url.href\n}\n"],"mappings":"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","names":[]}
@@ -171,9 +171,66 @@ interface OrderEvent {
171
171
  data: Record<string, unknown> | null;
172
172
  createdAt: string;
173
173
  }
174
+ /**
175
+ * requested → quoted → accepted (an order exists) | declined | expired → (re-quoted).
176
+ * `cancelled` is the merchant or the operations system withdrawing it.
177
+ */
178
+ type QuoteStatus = 'requested' | 'quoted' | 'accepted' | 'declined' | 'expired' | 'cancelled';
179
+ interface QuoteLine {
180
+ id: string;
181
+ sku: string;
182
+ name: string;
183
+ quantity: number;
184
+ /** The catalog price when the buyer asked, for reference; null for a line the offer added off-catalog. */
185
+ listAmount: Money | null;
186
+ /** The offered price; null until the quote is priced. */
187
+ unitAmount: Money | null;
188
+ totalAmount: Money | null;
189
+ image?: string;
190
+ }
191
+ /**
192
+ * A request for quote and, once priced, the offer. The buyer needs no account: a guest's quote is reached
193
+ * through the access-key link emailed to them, a signed-in buyer's also through /account.
194
+ */
195
+ interface StoreQuote {
196
+ id: string;
197
+ /** Human quote number without the prefix, e.g. "1001" (shown as Q1001). */
198
+ number: string;
199
+ status: QuoteStatus;
200
+ currency: string;
201
+ customer: OrderCustomer;
202
+ company: string | null;
203
+ /** What the buyer wrote with the request: lead time, use, delivery constraints. */
204
+ message: string | null;
205
+ shippingAddress: Address | null;
206
+ lines: QuoteLine[];
207
+ /** Null until priced. */
208
+ subtotal: Money | null;
209
+ shipping: Money | null;
210
+ tax: Money | null;
211
+ total: Money | null;
212
+ /** The merchant's note with the offer (terms, lead time). */
213
+ offerNote: string | null;
214
+ /** When the offer lapses; null for an open-ended offer or an unpriced request. */
215
+ expiresAt: string | null;
216
+ /** The operations system's own quote number, stamped back through the Store API. */
217
+ externalReference: string | null;
218
+ /** The order placed when the buyer accepted. */
219
+ orderId: string | null;
220
+ declineReason: string | null;
221
+ metadata: Record<string, string>;
222
+ /** Secret in the buyer's emailed link; never exposed through the Store API. */
223
+ accessKey: string;
224
+ createdAt: string;
225
+ updatedAt: string;
226
+ quotedAt: string | null;
227
+ acceptedAt: string | null;
228
+ declinedAt: string | null;
229
+ cancelledAt: string | null;
230
+ }
174
231
 
175
232
  /**
176
- * An in-memory snapshot of the catalogue with the tree and the lookups the
233
+ * An in-memory snapshot of the catalog with the tree and the lookups the
177
234
  * pages need. Built from plain data — the store service builds one from the
178
235
  * database per request (cached briefly per isolate); tests build one from
179
236
  * fixtures.
@@ -219,7 +276,7 @@ declare class Catalog {
219
276
  interface RoutePatterns {
220
277
  product: string;
221
278
  category: string;
222
- /** Where the full catalogue lives. */
279
+ /** Where the full catalog lives. */
223
280
  shop: string;
224
281
  }
225
282
  declare const DEFAULT_ROUTES: RoutePatterns;
@@ -406,6 +463,17 @@ interface StorefrontInput {
406
463
  */
407
464
  numberStart?: number;
408
465
  };
466
+ /** Request-for-quote (see store/quotes.ts). */
467
+ quotes?: {
468
+ /** Prefix for quote numbers: 'Q' → Q1001 (default 'Q'). */
469
+ numberPrefix?: string;
470
+ /** The lowest number a new quote may take (default 1001). */
471
+ numberStart?: number;
472
+ /** Per line. Quotes are for volume, so this is far above checkout's limit (default 100000). */
473
+ maxQuantity?: number;
474
+ /** How many quote requests one email address may send per hour before it is told to wait (default 5). */
475
+ maxRequestsPerHour?: number;
476
+ };
409
477
  /**
410
478
  * Discount codes the store prices itself (see discounts.ts). A code with a
411
479
  * `partner` attributes the order to that partner and snapshots the
@@ -420,7 +488,7 @@ interface StorefrontInput {
420
488
  /** `?code=` links that apply a code for a while. Defaults: param "code", 30 days. */
421
489
  referral?: Partial<ReferralSettings>;
422
490
  }
423
- interface StorefrontConfig extends Omit<StorefrontInput, 'routes' | 'locale' | 'checkout' | 'inventory' | 'orders' | 'discounts' | 'quantityDiscounts' | 'partners' | 'referral'> {
491
+ interface StorefrontConfig extends Omit<StorefrontInput, 'routes' | 'locale' | 'checkout' | 'inventory' | 'orders' | 'quotes' | 'discounts' | 'quantityDiscounts' | 'partners' | 'referral'> {
424
492
  locale: string;
425
493
  routes: Routes;
426
494
  checkout: {
@@ -435,6 +503,12 @@ interface StorefrontConfig extends Omit<StorefrontInput, 'routes' | 'locale' | '
435
503
  numberPrefix: string;
436
504
  numberStart: number;
437
505
  };
506
+ quotes: {
507
+ numberPrefix: string;
508
+ numberStart: number;
509
+ maxQuantity: number;
510
+ maxRequestsPerHour: number;
511
+ };
438
512
  discounts: DiscountCode[];
439
513
  quantityDiscounts: QuantityDiscount[];
440
514
  partners: PartnerProfile[];
@@ -446,4 +520,4 @@ interface StorefrontConfig extends Omit<StorefrontInput, 'routes' | 'locale' | '
446
520
  */
447
521
  declare function defineStorefront(input: StorefrontInput): StorefrontConfig;
448
522
 
449
- export { type Address as A, defineStorefront as B, Catalog as C, DEFAULT_ROUTES as D, discountAmount as E, type Fulfillment as F, findDiscount as G, flatRate as H, freeShipping as I, hasInternationalRegion as J, normalizeDiscountCode as K, publicDiscount as L, type Money as M, quantityLabel as N, type Order as O, type PartnerProfile as P, type QuantityDiscount as Q, type ReferralSettings as R, type ShippingMethod as S, quantityTier as T, regionForCountry as U, shippingForCountry as V, shippingForRegion as W, type Availability as a, type AvailabilityStatus as b, type Category as c, type CategoryNode as d, type Customer as e, DISCOUNT_COOKIE as f, type DiscountCode as g, type FulfillmentLine as h, type OrderCustomer as i, type OrderEvent as j, type OrderLine as k, type OrderPayment as l, type OrderStatus as m, type PaymentStatus as n, type Product as o, type ProductImage as p, type PublicDiscount as q, type ResolvedShipping as r, type RoutePatterns as s, type Routes as t, type ShippingOption as u, type ShippingRegion as v, type StorefrontConfig as w, type StorefrontInput as x, commissionAmount as y, defineRoutes as z };
523
+ export { type Address as A, type StorefrontInput as B, Catalog as C, DEFAULT_ROUTES as D, commissionAmount as E, type Fulfillment as F, defineRoutes as G, defineStorefront as H, discountAmount as I, findDiscount as J, flatRate as K, freeShipping as L, type Money as M, hasInternationalRegion as N, type Order as O, type PartnerProfile as P, type QuoteStatus as Q, type ReferralSettings as R, type StoreQuote as S, normalizeDiscountCode as T, publicDiscount as U, quantityLabel as V, quantityTier as W, regionForCountry as X, shippingForCountry as Y, shippingForRegion as Z, type Availability as a, type AvailabilityStatus as b, type Category as c, type CategoryNode as d, type Customer as e, DISCOUNT_COOKIE as f, type DiscountCode as g, type FulfillmentLine as h, type OrderCustomer as i, type OrderEvent as j, type OrderLine as k, type OrderPayment as l, type OrderStatus as m, type PaymentStatus as n, type Product as o, type ProductImage as p, type PublicDiscount as q, type QuantityDiscount as r, type QuoteLine as s, type ResolvedShipping as t, type RoutePatterns as u, type Routes as v, type ShippingMethod as w, type ShippingOption as x, type ShippingRegion as y, type StorefrontConfig as z };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { M as Money } from './config-C4YCi7m4.js';
2
- export { A as Address, a as Availability, b as AvailabilityStatus, C as Catalog, c as Category, d as CategoryNode, e as Customer, D as DEFAULT_ROUTES, f as DISCOUNT_COOKIE, g as DiscountCode, F as Fulfillment, h as FulfillmentLine, O as Order, i as OrderCustomer, j as OrderEvent, k as OrderLine, l as OrderPayment, m as OrderStatus, P as PartnerProfile, n as PaymentStatus, o as Product, p as ProductImage, q as PublicDiscount, Q as QuantityDiscount, R as ReferralSettings, r as ResolvedShipping, s as RoutePatterns, t as Routes, S as ShippingMethod, u as ShippingOption, v as ShippingRegion, w as StorefrontConfig, x as StorefrontInput, y as commissionAmount, z as defineRoutes, B as defineStorefront, E as discountAmount, G as findDiscount, H as flatRate, I as freeShipping, J as hasInternationalRegion, K as normalizeDiscountCode, L as publicDiscount, N as quantityLabel, T as quantityTier, U as regionForCountry, V as shippingForCountry, W as shippingForRegion } from './config-C4YCi7m4.js';
1
+ import { M as Money } from './config-CCxTka_z.js';
2
+ export { A as Address, a as Availability, b as AvailabilityStatus, C as Catalog, c as Category, d as CategoryNode, e as Customer, D as DEFAULT_ROUTES, f as DISCOUNT_COOKIE, g as DiscountCode, F as Fulfillment, h as FulfillmentLine, O as Order, i as OrderCustomer, j as OrderEvent, k as OrderLine, l as OrderPayment, m as OrderStatus, P as PartnerProfile, n as PaymentStatus, o as Product, p as ProductImage, q as PublicDiscount, r as QuantityDiscount, s as QuoteLine, Q as QuoteStatus, R as ReferralSettings, t as ResolvedShipping, u as RoutePatterns, v as Routes, w as ShippingMethod, x as ShippingOption, y as ShippingRegion, S as StoreQuote, z as StorefrontConfig, B as StorefrontInput, E as commissionAmount, G as defineRoutes, H as defineStorefront, I as discountAmount, J as findDiscount, K as flatRate, L as freeShipping, N as hasInternationalRegion, T as normalizeDiscountCode, U as publicDiscount, V as quantityLabel, W as quantityTier, X as regionForCountry, Y as shippingForCountry, Z as shippingForRegion } from './config-CCxTka_z.js';
3
3
 
4
4
  /**
5
5
  * Format minor units for display. Whole amounts drop the cents ($1,099), which
@@ -16,7 +16,7 @@ declare function slugify(input: string): string;
16
16
  /**
17
17
  * A cart is client-side state — lines the customer has chosen, with a snapshot
18
18
  * of what they saw (name, price) so the cart page can render without the
19
- * catalogue. It is never trusted for money: checkout re-prices every SKU on the
19
+ * catalog. It is never trusted for money: checkout re-prices every SKU on the
20
20
  * server. These are pure functions; the browser store wraps them.
21
21
  */
22
22
  interface CartLine {
package/dist/index.js CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  slugify,
31
31
  validateDiscounts,
32
32
  validateQuantityDiscounts
33
- } from "./chunk-56ODALDY.js";
33
+ } from "./chunk-NM5YP764.js";
34
34
  import {
35
35
  addMoney,
36
36
  formatMoney,
@@ -103,6 +103,12 @@ function defineStorefront(input) {
103
103
  numberPrefix: input.orders?.numberPrefix ?? "",
104
104
  numberStart: Math.max(1, Math.trunc(input.orders?.numberStart ?? 1001))
105
105
  },
106
+ quotes: {
107
+ numberPrefix: input.quotes?.numberPrefix ?? "Q",
108
+ numberStart: Math.max(1, Math.trunc(input.quotes?.numberStart ?? 1001)),
109
+ maxQuantity: Math.max(1, Math.trunc(input.quotes?.maxQuantity ?? 1e5)),
110
+ maxRequestsPerHour: Math.max(1, Math.trunc(input.quotes?.maxRequestsPerHour ?? 5))
111
+ },
106
112
  discounts: input.discounts ?? [],
107
113
  quantityDiscounts: input.quantityDiscounts ?? [],
108
114
  partners: input.partners ?? [],
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/routes.ts","../src/config.ts","../src/http.ts"],"sourcesContent":["import type { CategoryNode, Product } from './types.js'\nimport type { Catalog } from './catalog.js'\n\n/**\n * URL patterns. Tokens:\n * :slug product slug\n * :sku product SKU\n * :path category slug path joined with '/', e.g. toyota/tacoma-95-04\n *\n * Defaults are conventional; a migrated store sets them to match its legacy\n * URLs so nothing indexed ever moves. Patterns should start and end with '/'.\n */\nexport interface RoutePatterns {\n product: string\n category: string\n /** Where the full catalogue lives. */\n shop: string\n}\n\nexport const DEFAULT_ROUTES: RoutePatterns = {\n product: '/products/:slug/',\n category: '/collections/:path/',\n shop: '/shop/',\n}\n\nexport interface Routes {\n product(product: Product, catalog: Catalog): string\n category(node: CategoryNode): string\n shop(): string\n patterns: RoutePatterns\n}\n\nexport function defineRoutes(partial: Partial<RoutePatterns> = {}): Routes {\n const patterns: RoutePatterns = { ...DEFAULT_ROUTES, ...partial }\n\n const fill = (pattern: string, vars: Record<string, string>) =>\n pattern.replace(/:(\\w+)/g, (_, key: string) => {\n const v = vars[key]\n if (v === undefined) throw new Error(`Route pattern \"${pattern}\" needs :${key}`)\n return v\n })\n\n return {\n patterns,\n product(product, catalog) {\n const crumbs = catalog.breadcrumbs(product)\n return fill(patterns.product, {\n slug: product.slug,\n sku: encodeURIComponent(product.sku),\n path: crumbs.map((c) => c.slug).join('/'),\n })\n },\n category(node) {\n return fill(patterns.category, { path: node.path.join('/') })\n },\n shop() {\n return patterns.shop\n },\n }\n}\n","import { defineRoutes, type RoutePatterns, type Routes } from './routes'\nimport type { ShippingOption } from './shipping'\nimport { validateDiscounts, validateQuantityDiscounts, type DiscountCode, type PartnerProfile, type QuantityDiscount, type ReferralSettings } from './discounts'\n\nexport interface StorefrontInput {\n /** Store name, used in titles, emails and the Store API health check. */\n name: string\n /** Canonical public origin, e.g. https://www.example.com (no trailing slash). */\n url: string\n /** ISO 4217, lower case: 'usd'. */\n currency: string\n /** BCP 47 for formatting: 'en-US'. */\n locale?: string\n routes?: Partial<RoutePatterns>\n /** At least one option. Options that name `countries` are only offered to those countries. */\n shipping: ShippingOption[]\n /** ISO 3166-1 alpha-2 codes the store ships to. */\n shipToCountries: string[]\n contact?: {\n phone?: string\n phoneE164?: string\n email?: string\n }\n checkout?: {\n /** Per line. */\n maxQuantity?: number\n allowPromotionCodes?: boolean\n collectPhone?: boolean\n }\n inventory?: {\n /** `available <= lowStockThreshold` shows as low stock. */\n lowStockThreshold?: number\n }\n orders?: {\n /** Prefix for human order numbers: 'VLD-' → VLD-1001. Shown on pages, emails and receipts, and sent to SKU.io. */\n numberPrefix?: string\n /**\n * The lowest number a new order may take (default 1001). New orders take the next number above the highest\n * order on file, or this, whichever is larger: set it above numbers an earlier platform used (4200 after\n * WooCommerce's VLD4154) so a new order never repeats an old one.\n */\n numberStart?: number\n }\n /**\n * Discount codes the store prices itself (see discounts.ts). A code with a\n * `partner` attributes the order to that partner and snapshots the\n * commission owed. Prefer these over processor promotion codes: they work\n * under every payment adapter and land on the order.\n */\n discounts?: DiscountCode[]\n /** Percent off by units in the order (see discounts.ts). Never stacks with a code: the larger saving wins. */\n quantityDiscounts?: QuantityDiscount[]\n /** Profiles for the partners codes name: welcome banner + portal sign-in. */\n partners?: PartnerProfile[]\n /** `?code=` links that apply a code for a while. Defaults: param \"code\", 30 days. */\n referral?: Partial<ReferralSettings>\n}\n\nexport interface StorefrontConfig extends Omit<StorefrontInput, 'routes' | 'locale' | 'checkout' | 'inventory' | 'orders' | 'discounts' | 'quantityDiscounts' | 'partners' | 'referral'> {\n locale: string\n routes: Routes\n checkout: {\n maxQuantity: number\n allowPromotionCodes: boolean\n collectPhone: boolean\n }\n inventory: {\n lowStockThreshold: number\n }\n orders: {\n numberPrefix: string\n numberStart: number\n }\n discounts: DiscountCode[]\n quantityDiscounts: QuantityDiscount[]\n partners: PartnerProfile[]\n referral: ReferralSettings\n}\n\n/**\n * Validate and fill in a store's configuration. Every store has exactly one\n * of these, exported from storefront.config.ts.\n */\nexport function defineStorefront(input: StorefrontInput): StorefrontConfig {\n if (!/^https?:\\/\\//.test(input.url)) throw new Error(`storefront.url must be absolute, got ${input.url}`)\n if (input.url.endsWith('/')) throw new Error('storefront.url must not end with a slash')\n if (!input.shipping.length) throw new Error('storefront.shipping needs at least one option')\n if (!input.shipToCountries.length) throw new Error('storefront.shipToCountries needs at least one country')\n validateDiscounts(input.discounts ?? [], input.currency)\n validateQuantityDiscounts(input.quantityDiscounts ?? [])\n const partnerIds = new Set<string>()\n for (const p of input.partners ?? []) {\n if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(p.id)) throw new Error(`partner id \"${p.id}\" must be lower-case letters, digits and dashes`)\n if (partnerIds.has(p.id)) throw new Error(`partner ${p.id} is defined twice`)\n if (p.email && !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(p.email)) throw new Error(`partner ${p.id}: email is not an address`)\n partnerIds.add(p.id)\n }\n const referralParam = input.referral?.param ?? 'code'\n if (!/^[a-z][a-z0-9_]{0,31}$/.test(referralParam)) throw new Error(`storefront.referral.param \"${referralParam}\" must be a short lower-case query parameter`)\n\n return {\n ...input,\n locale: input.locale ?? 'en-US',\n routes: defineRoutes(input.routes),\n checkout: {\n maxQuantity: input.checkout?.maxQuantity ?? 10,\n allowPromotionCodes: input.checkout?.allowPromotionCodes ?? true,\n collectPhone: input.checkout?.collectPhone ?? true,\n },\n inventory: {\n lowStockThreshold: input.inventory?.lowStockThreshold ?? 3,\n },\n orders: {\n numberPrefix: input.orders?.numberPrefix ?? '',\n numberStart: Math.max(1, Math.trunc(input.orders?.numberStart ?? 1001)),\n },\n discounts: input.discounts ?? [],\n quantityDiscounts: input.quantityDiscounts ?? [],\n partners: input.partners ?? [],\n referral: {\n param: referralParam,\n cookieDays: input.referral?.cookieDays ?? 30,\n },\n }\n}\n","/**\n * Framework-agnostic request helpers for browser form endpoints. The store\n * disables Astro's global origin check so the bearer-authenticated Store API\n * (machine traffic, no Origin header) is never rejected; these keep the\n * browser form POSTs same-origin instead.\n */\n\n/**\n * True when the request is a same-origin browser navigation/form post, or a\n * non-browser call (no Origin, no Sec-Fetch-Site — e.g. the Store API). False\n * only when a cross-site browser context is explicit. Use it to guard mutating\n * form handlers: `if (!sameOrigin(request, url)) return reject`.\n */\nexport function sameOrigin(request: Request, url: URL): boolean {\n const site = request.headers.get('sec-fetch-site')\n if (site && site !== 'same-origin' && site !== 'none') return false\n const origin = request.headers.get('origin')\n if (origin && origin !== url.origin) return false\n return true\n}\n\n/** The caller's address, from Cloudflare's header first, then a proxy's. */\nexport function clientAddress(request: Request): string {\n return request.headers.get('cf-connecting-ip') ?? request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/routes.ts","../src/config.ts","../src/http.ts"],"sourcesContent":["import type { CategoryNode, Product } from './types.js'\nimport type { Catalog } from './catalog.js'\n\n/**\n * URL patterns. Tokens:\n * :slug product slug\n * :sku product SKU\n * :path category slug path joined with '/', e.g. toyota/tacoma-95-04\n *\n * Defaults are conventional; a migrated store sets them to match its legacy\n * URLs so nothing indexed ever moves. Patterns should start and end with '/'.\n */\nexport interface RoutePatterns {\n product: string\n category: string\n /** Where the full catalog lives. */\n shop: string\n}\n\nexport const DEFAULT_ROUTES: RoutePatterns = {\n product: '/products/:slug/',\n category: '/collections/:path/',\n shop: '/shop/',\n}\n\nexport interface Routes {\n product(product: Product, catalog: Catalog): string\n category(node: CategoryNode): string\n shop(): string\n patterns: RoutePatterns\n}\n\nexport function defineRoutes(partial: Partial<RoutePatterns> = {}): Routes {\n const patterns: RoutePatterns = { ...DEFAULT_ROUTES, ...partial }\n\n const fill = (pattern: string, vars: Record<string, string>) =>\n pattern.replace(/:(\\w+)/g, (_, key: string) => {\n const v = vars[key]\n if (v === undefined) throw new Error(`Route pattern \"${pattern}\" needs :${key}`)\n return v\n })\n\n return {\n patterns,\n product(product, catalog) {\n const crumbs = catalog.breadcrumbs(product)\n return fill(patterns.product, {\n slug: product.slug,\n sku: encodeURIComponent(product.sku),\n path: crumbs.map((c) => c.slug).join('/'),\n })\n },\n category(node) {\n return fill(patterns.category, { path: node.path.join('/') })\n },\n shop() {\n return patterns.shop\n },\n }\n}\n","import { defineRoutes, type RoutePatterns, type Routes } from './routes'\nimport type { ShippingOption } from './shipping'\nimport { validateDiscounts, validateQuantityDiscounts, type DiscountCode, type PartnerProfile, type QuantityDiscount, type ReferralSettings } from './discounts'\n\nexport interface StorefrontInput {\n /** Store name, used in titles, emails and the Store API health check. */\n name: string\n /** Canonical public origin, e.g. https://www.example.com (no trailing slash). */\n url: string\n /** ISO 4217, lower case: 'usd'. */\n currency: string\n /** BCP 47 for formatting: 'en-US'. */\n locale?: string\n routes?: Partial<RoutePatterns>\n /** At least one option. Options that name `countries` are only offered to those countries. */\n shipping: ShippingOption[]\n /** ISO 3166-1 alpha-2 codes the store ships to. */\n shipToCountries: string[]\n contact?: {\n phone?: string\n phoneE164?: string\n email?: string\n }\n checkout?: {\n /** Per line. */\n maxQuantity?: number\n allowPromotionCodes?: boolean\n collectPhone?: boolean\n }\n inventory?: {\n /** `available <= lowStockThreshold` shows as low stock. */\n lowStockThreshold?: number\n }\n orders?: {\n /** Prefix for human order numbers: 'VLD-' → VLD-1001. Shown on pages, emails and receipts, and sent to SKU.io. */\n numberPrefix?: string\n /**\n * The lowest number a new order may take (default 1001). New orders take the next number above the highest\n * order on file, or this, whichever is larger: set it above numbers an earlier platform used (4200 after\n * WooCommerce's VLD4154) so a new order never repeats an old one.\n */\n numberStart?: number\n }\n /** Request-for-quote (see store/quotes.ts). */\n quotes?: {\n /** Prefix for quote numbers: 'Q' → Q1001 (default 'Q'). */\n numberPrefix?: string\n /** The lowest number a new quote may take (default 1001). */\n numberStart?: number\n /** Per line. Quotes are for volume, so this is far above checkout's limit (default 100000). */\n maxQuantity?: number\n /** How many quote requests one email address may send per hour before it is told to wait (default 5). */\n maxRequestsPerHour?: number\n }\n /**\n * Discount codes the store prices itself (see discounts.ts). A code with a\n * `partner` attributes the order to that partner and snapshots the\n * commission owed. Prefer these over processor promotion codes: they work\n * under every payment adapter and land on the order.\n */\n discounts?: DiscountCode[]\n /** Percent off by units in the order (see discounts.ts). Never stacks with a code: the larger saving wins. */\n quantityDiscounts?: QuantityDiscount[]\n /** Profiles for the partners codes name: welcome banner + portal sign-in. */\n partners?: PartnerProfile[]\n /** `?code=` links that apply a code for a while. Defaults: param \"code\", 30 days. */\n referral?: Partial<ReferralSettings>\n}\n\nexport interface StorefrontConfig extends Omit<StorefrontInput, 'routes' | 'locale' | 'checkout' | 'inventory' | 'orders' | 'quotes' | 'discounts' | 'quantityDiscounts' | 'partners' | 'referral'> {\n locale: string\n routes: Routes\n checkout: {\n maxQuantity: number\n allowPromotionCodes: boolean\n collectPhone: boolean\n }\n inventory: {\n lowStockThreshold: number\n }\n orders: {\n numberPrefix: string\n numberStart: number\n }\n quotes: {\n numberPrefix: string\n numberStart: number\n maxQuantity: number\n maxRequestsPerHour: number\n }\n discounts: DiscountCode[]\n quantityDiscounts: QuantityDiscount[]\n partners: PartnerProfile[]\n referral: ReferralSettings\n}\n\n/**\n * Validate and fill in a store's configuration. Every store has exactly one\n * of these, exported from storefront.config.ts.\n */\nexport function defineStorefront(input: StorefrontInput): StorefrontConfig {\n if (!/^https?:\\/\\//.test(input.url)) throw new Error(`storefront.url must be absolute, got ${input.url}`)\n if (input.url.endsWith('/')) throw new Error('storefront.url must not end with a slash')\n if (!input.shipping.length) throw new Error('storefront.shipping needs at least one option')\n if (!input.shipToCountries.length) throw new Error('storefront.shipToCountries needs at least one country')\n validateDiscounts(input.discounts ?? [], input.currency)\n validateQuantityDiscounts(input.quantityDiscounts ?? [])\n const partnerIds = new Set<string>()\n for (const p of input.partners ?? []) {\n if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(p.id)) throw new Error(`partner id \"${p.id}\" must be lower-case letters, digits and dashes`)\n if (partnerIds.has(p.id)) throw new Error(`partner ${p.id} is defined twice`)\n if (p.email && !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(p.email)) throw new Error(`partner ${p.id}: email is not an address`)\n partnerIds.add(p.id)\n }\n const referralParam = input.referral?.param ?? 'code'\n if (!/^[a-z][a-z0-9_]{0,31}$/.test(referralParam)) throw new Error(`storefront.referral.param \"${referralParam}\" must be a short lower-case query parameter`)\n\n return {\n ...input,\n locale: input.locale ?? 'en-US',\n routes: defineRoutes(input.routes),\n checkout: {\n maxQuantity: input.checkout?.maxQuantity ?? 10,\n allowPromotionCodes: input.checkout?.allowPromotionCodes ?? true,\n collectPhone: input.checkout?.collectPhone ?? true,\n },\n inventory: {\n lowStockThreshold: input.inventory?.lowStockThreshold ?? 3,\n },\n orders: {\n numberPrefix: input.orders?.numberPrefix ?? '',\n numberStart: Math.max(1, Math.trunc(input.orders?.numberStart ?? 1001)),\n },\n quotes: {\n numberPrefix: input.quotes?.numberPrefix ?? 'Q',\n numberStart: Math.max(1, Math.trunc(input.quotes?.numberStart ?? 1001)),\n maxQuantity: Math.max(1, Math.trunc(input.quotes?.maxQuantity ?? 100_000)),\n maxRequestsPerHour: Math.max(1, Math.trunc(input.quotes?.maxRequestsPerHour ?? 5)),\n },\n discounts: input.discounts ?? [],\n quantityDiscounts: input.quantityDiscounts ?? [],\n partners: input.partners ?? [],\n referral: {\n param: referralParam,\n cookieDays: input.referral?.cookieDays ?? 30,\n },\n }\n}\n","/**\n * Framework-agnostic request helpers for browser form endpoints. The store\n * disables Astro's global origin check so the bearer-authenticated Store API\n * (machine traffic, no Origin header) is never rejected; these keep the\n * browser form POSTs same-origin instead.\n */\n\n/**\n * True when the request is a same-origin browser navigation/form post, or a\n * non-browser call (no Origin, no Sec-Fetch-Site — e.g. the Store API). False\n * only when a cross-site browser context is explicit. Use it to guard mutating\n * form handlers: `if (!sameOrigin(request, url)) return reject`.\n */\nexport function sameOrigin(request: Request, url: URL): boolean {\n const site = request.headers.get('sec-fetch-site')\n if (site && site !== 'same-origin' && site !== 'none') return false\n const origin = request.headers.get('origin')\n if (origin && origin !== url.origin) return false\n return true\n}\n\n/** The caller's address, from Cloudflare's header first, then a proxy's. */\nexport function clientAddress(request: Request): string {\n return request.headers.get('cf-connecting-ip') ?? request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'\n}\n"],"mappings":"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","names":[]}
package/dist/mail.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { M as MailAdapter, a as MailMessage } from './types-CWreCyxc.js';
2
- import { w as StorefrontConfig, O as Order, F as Fulfillment } from './config-C4YCi7m4.js';
2
+ import { z as StorefrontConfig, O as Order, S as StoreQuote, F as Fulfillment } from './config-CCxTka_z.js';
3
3
  export { ConsoleMailOptions, consoleMail } from './mail/console.js';
4
4
  export { MailSendError, SendgridMailOptions, sendgridMail } from './mail/sendgrid.js';
5
5
  export { CloudflareMailOptions, CloudflareSendEmailBinding, cloudflareMail } from './mail/cloudflare.js';
@@ -31,5 +31,13 @@ declare function ownerPasswordResetMail(input: {
31
31
  expiresMinutes: number;
32
32
  }, ctx: MailContext): RenderedMail;
33
33
  declare function merchantNewOrderMail(order: Order, ctx: MailContext): RenderedMail;
34
+ /** Where a buyer opens a quote without signing in: the access key is the credential. */
35
+ declare function quoteUrl(quote: StoreQuote, siteUrl: string): string;
36
+ /** To the buyer: we have your request. Carries the link that is their way back in — no account needed. */
37
+ declare function quoteReceivedMail(quote: StoreQuote, ctx: MailContext): RenderedMail;
38
+ /** To the buyer: the quote is priced and waiting for their answer. */
39
+ declare function quoteReadyMail(quote: StoreQuote, ctx: MailContext): RenderedMail;
40
+ /** To the merchant: a new request, or the buyer's answer to an offer. */
41
+ declare function merchantQuoteMail(quote: StoreQuote, ctx: MailContext, event: 'requested' | 'accepted' | 'declined', orderNumber?: string | null): RenderedMail;
34
42
 
35
- export { type MailContext, type RenderedMail, escapeHtml, magicLinkMail, merchantNewOrderMail, orderConfirmationMail, ownerPasswordResetMail, partnerSignInMail, shippingConfirmationMail };
43
+ export { type MailContext, type RenderedMail, escapeHtml, magicLinkMail, merchantNewOrderMail, merchantQuoteMail, orderConfirmationMail, ownerPasswordResetMail, partnerSignInMail, quoteReadyMail, quoteReceivedMail, quoteUrl, shippingConfirmationMail };
package/dist/mail.js CHANGED
@@ -12,11 +12,15 @@ import {
12
12
  escapeHtml,
13
13
  magicLinkMail,
14
14
  merchantNewOrderMail,
15
+ merchantQuoteMail,
15
16
  orderConfirmationMail,
16
17
  ownerPasswordResetMail,
17
18
  partnerSignInMail,
19
+ quoteReadyMail,
20
+ quoteReceivedMail,
21
+ quoteUrl,
18
22
  shippingConfirmationMail
19
- } from "./chunk-UCREHLZH.js";
23
+ } from "./chunk-BD274TXU.js";
20
24
  import "./chunk-4PTKC5PH.js";
21
25
  export {
22
26
  MailSendError,
@@ -25,9 +29,13 @@ export {
25
29
  escapeHtml,
26
30
  magicLinkMail,
27
31
  merchantNewOrderMail,
32
+ merchantQuoteMail,
28
33
  orderConfirmationMail,
29
34
  ownerPasswordResetMail,
30
35
  partnerSignInMail,
36
+ quoteReadyMail,
37
+ quoteReceivedMail,
38
+ quoteUrl,
31
39
  sendgridMail,
32
40
  shippingConfirmationMail
33
41
  };
@@ -1,4 +1,4 @@
1
- import { O as Order } from '../config-C4YCi7m4.js';
1
+ import { O as Order } from '../config-CCxTka_z.js';
2
2
  import { PaymentAdapter, PaymentContext, PaymentEvent } from '../payments.js';
3
3
  import '../store.js';
4
4
  import '../driver-Cr4l6GgG.js';
@@ -1,6 +1,6 @@
1
1
  import { PaymentAdapter } from '../payments.js';
2
2
  import '../store.js';
3
- import '../config-C4YCi7m4.js';
3
+ import '../config-CCxTka_z.js';
4
4
  import '../driver-Cr4l6GgG.js';
5
5
  import '../types-CWreCyxc.js';
6
6
 
@@ -1,4 +1,4 @@
1
- import { O as Order } from '../config-C4YCi7m4.js';
1
+ import { O as Order } from '../config-CCxTka_z.js';
2
2
  import { PaymentContext, PaymentAdapter } from '../payments.js';
3
3
  import '../store.js';
4
4
  import '../driver-Cr4l6GgG.js';
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  escapeHtml
3
- } from "../chunk-UCREHLZH.js";
3
+ } from "../chunk-BD274TXU.js";
4
4
  import {
5
5
  formatMoney
6
6
  } from "../chunk-4PTKC5PH.js";
@@ -1,4 +1,4 @@
1
- import { A as Address, O as Order } from '../config-C4YCi7m4.js';
1
+ import { A as Address, O as Order } from '../config-CCxTka_z.js';
2
2
  import { Quote } from '../store.js';
3
3
  import { PaymentEvent, PaymentAdapter } from '../payments.js';
4
4
  import '../driver-Cr4l6GgG.js';
@@ -1,5 +1,5 @@
1
1
  import Stripe from 'stripe';
2
- import { O as Order } from '../config-C4YCi7m4.js';
2
+ import { O as Order } from '../config-CCxTka_z.js';
3
3
  import { PaymentContext, PaymentEvent, PaymentAdapter } from '../payments.js';
4
4
  import '../store.js';
5
5
  import '../driver-Cr4l6GgG.js';
@@ -1,5 +1,5 @@
1
1
  import { Store } from './store.js';
2
- import { O as Order, M as Money } from './config-C4YCi7m4.js';
2
+ import { O as Order, M as Money } from './config-CCxTka_z.js';
3
3
  import './driver-Cr4l6GgG.js';
4
4
  import './types-CWreCyxc.js';
5
5