your-ai-workflow-firebase-os 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/index.d.ts +5 -0
- package/dist/server.cjs.js +25 -1
- package/dist/server.es.js +332 -71
- package/package.json +1 -1
- package/server.template.ts +10 -4
package/dist/server/index.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export interface ApiRoutesOptions {
|
|
|
12
12
|
}
|
|
13
13
|
export interface AuthedRequest extends Request {
|
|
14
14
|
user?: VerifiedUser;
|
|
15
|
+
/** The caller's own ID token — Firestore reads are made as them, never as us. */
|
|
16
|
+
idToken?: string;
|
|
15
17
|
}
|
|
16
18
|
export declare function apiRoutes(options?: ApiRoutesOptions): Router;
|
|
17
19
|
export { projectId, verifyIdToken } from './auth';
|
|
@@ -19,3 +21,6 @@ export { runSelfTest, selfTestAllowed } from './selftest';
|
|
|
19
21
|
export { checkoutProblem, grantsAccess, returnUrls, stripeMode, MAX_ADHOC_AMOUNT, MIN_ADHOC_AMOUNT, } from './stripeClient';
|
|
20
22
|
export type { CheckoutRequest } from './stripeClient';
|
|
21
23
|
export type { Check, SelfTestResult } from './selftest';
|
|
24
|
+
export { TEMPLATES, renderTemplate, escapeHtml, fill, adminEmails } from './email';
|
|
25
|
+
export type { EmailTemplate, RenderedEmail } from './email';
|
|
26
|
+
export { readDocument, emailOnRecord, pathProblem, fromFields } from './firestore';
|
package/dist/server.cjs.js
CHANGED
|
@@ -1 +1,25 @@
|
|
|
1
|
-
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./chunk-DYteiM-1.cjs`);let t=require(`express`);t=e.i(t,1);let n=require(`node:crypto`),r=require(`stripe`);r=e.i(r,1);var i=`https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com`,a=60;function o(e,t,n,r=Math.floor(Date.now()/1e3)){return n?e.alg===`RS256`?e.kid?t.aud===n?t.iss===`https://securetoken.google.com/${n}`?t.sub?t.sub.length>128?`Token subject is not a valid uid.`:typeof t.exp!=`number`||t.exp<=r?`Token has expired.`:typeof t.iat!=`number`||t.iat>r+a?`Token was issued in the future.`:null:`Token has no subject.`:`Token has the wrong issuer.`:`Token was issued for a different Firebase project.`:`Token has no key id.`:`Token is not signed with RS256.`:`The server does not know its Firebase project id.`}function s(e){return Buffer.from(e.replace(/-/g,`+`).replace(/_/g,`/`),`base64`)}function c(e){let t=String(e||``).split(`.`);if(t.length!==3)return null;try{return{header:JSON.parse(s(t[0]).toString(`utf8`)),claims:JSON.parse(s(t[1]).toString(`utf8`)),signed:`${t[0]}.${t[1]}`,signature:s(t[2])}}catch{return null}}var l=null;async function u(){if(l&&l.expiresAt>Date.now())return l.keys;let e=await fetch(i);if(!e.ok)throw Error(`Could not fetch Google signing keys (${e.status}).`);let t=await e.json(),n=/max-age=(\d+)/.exec(e.headers.get(`cache-control`)||``);return l={keys:t,expiresAt:Date.now()+(n?Number(n[1]):3600)*1e3},t}async function d(e,t){let r=c(e);if(!r)throw Error(`That is not a valid sign-in token.`);let i=o(r.header,r.claims,t);if(i)throw Error(i);let a=(await u())[r.header.kid];if(!a)throw Error(`Token was signed with a key Google no longer publishes.`);let s=(0,n.createVerify)(`RSA-SHA256`);if(s.update(r.signed),s.end(),!s.verify(a,r.signature))throw Error(`Token signature does not check out.`);return{uid:r.claims.sub,email:r.claims.email||null,emailVerified:!!r.claims.email_verified}}function f(){return(process.env.VITE_FIREBASE_PROJECT_ID||process.env.FIREBASE_PROJECT_ID||``).trim()}var p=null;function m(){return(process.env.STRIPE_SECRET_KEY||``).trim()}function h(){let e=m();return e?e.startsWith(`sk_live_`)?`live`:`test`:null}function g(){let e=m();if(!e)throw Error(`STRIPE_SECRET_KEY is not set.`);return p||=new r.default(e),p}var _=Number(process.env.STRIPE_MAX_ADHOC_AMOUNT||1e4),v=.5;function y(e){let{priceId:t,amount:n,mode:r=`payment`,quantity:i=1}=e||{};if(!t&&n===void 0)return`This button does not say what is being sold.`;if(t&&n!==void 0)return`A payment has either a Stripe price or an amount, not both.`;if(r!==`payment`&&r!==`subscription`)return`A payment is either one-time or a subscription.`;if(!Number.isInteger(i)||i<1||i>999)return`Quantity must be a whole number between 1 and 999.`;if(n!==void 0){if(r===`subscription`)return`A subscription needs a price created in Stripe — a typed amount cannot recur.`;if(typeof n!=`number`||!Number.isFinite(n))return`That amount is not a number.`;if(n<.5)return`The smallest amount that can be charged is ${v}.`;if(n>_)return`That amount is above the ${_} limit for typed prices. Use a price created in Stripe.`;if(!e.productName||!String(e.productName).trim())return`A typed amount needs a name for what is being bought.`}return t&&!/^price_[A-Za-z0-9]+$/.test(t)?`That does not look like a Stripe price id.`:null}function b(e){return Math.round(e*100)}function x(e,t,n){let r=String(e||``).replace(/\/+$/,``),i=(e,t)=>{let n=String(e||t);return n.startsWith(`/`)?n:`/${n}`};return{success:`${r}${i(t,`/payment-complete`)}?session_id={CHECKOUT_SESSION_ID}`,cancel:`${r}${i(n,`/payment-cancelled`)}`}}function S(e){return e===`active`||e===`trialing`||e===`past_due`}var C=e=>!!(process.env[e]||``).trim();function w(e){let t=(process.env.SELFTEST_TOKEN||``).trim();return t?String(e||``)===t:!0}async function T(){let e=[{name:`Server`,status:`pass`,detail:`The backend is running and reachable.`}],t=f();if(!t)e.push({name:`Firebase project`,status:`fail`,detail:`VITE_FIREBASE_PROJECT_ID is not set, so the server cannot tell who is signed in.`});else{e.push({name:`Firebase project`,status:`pass`,detail:`Signing in against ${t}.`});try{let t=await u();e.push({name:`Sign-in verification`,status:`pass`,detail:`Google's signing keys are reachable (${Object.keys(t).length} in rotation).`})}catch(t){e.push({name:`Sign-in verification`,status:`fail`,detail:`Could not reach Google's signing keys: ${t.message}`})}}if(!m())e.push({name:`Stripe`,status:`warn`,detail:`STRIPE_SECRET_KEY is not set. Payments are switched off until it is.`});else{let t=h();try{let n=await g().accounts.retrieve();e.push({name:`Stripe`,status:`pass`,detail:`Connected in ${t} mode to ${n.business_profile?.name||n.id}.`}),t===`live`&&e.push({name:`Live mode`,status:`warn`,detail:`This app is charging real cards. Every payment made here is real money.`})}catch(t){e.push({name:`Stripe`,status:`fail`,detail:`The key is set but Stripe refused it: ${t.message}`})}e.push(C(`STRIPE_WEBHOOK_SECRET`)?{name:`Stripe webhook`,status:`pass`,detail:`Signed webhooks will be accepted.`}:{name:`Stripe webhook`,status:`warn`,detail:`STRIPE_WEBHOOK_SECRET is not set. Payments still confirm when the payer returns; refunds and cancellations made in the Stripe dashboard will not reach the app until it is.`})}return e.push(C(`RESEND_API_KEY`)?{name:`Email`,status:`pass`,detail:`RESEND_API_KEY is set.`}:{name:`Email`,status:`warn`,detail:`RESEND_API_KEY is not set. The app will not send its own email.`}),{ok:!e.some(e=>e.status===`fail`),service:`your-ai-workflow-firebase-os`,checks:e}}var E=(e,t,n,r={})=>e.status(t).json({success:!1,error:n,...r});function D(e,t){let n=t instanceof Error?t.message:`Something went wrong on the server.`,r=n.includes(`STRIPE_SECRET_KEY`)||n.includes(`project id`);return E(e,r?503:500,n,r?{requiresSetup:!0}:{})}async function O(e,t,n){let r=String(e.headers.authorization||``),i=r.startsWith(`Bearer `)?r.slice(7):``;if(!i)return E(t,401,`Please sign in first.`);try{e.user=await d(i,f()),n()}catch(e){return E(t,401,e.message)}}async function k(e,t){return!t.email||!t.emailVerified?null:(await e.customers.list({email:t.email,limit:1})).data[0]||null}async function A(e,t){let n=await k(e,t);return n?(n.metadata?.uid!==t.uid&&await e.customers.update(n.id,{metadata:{...n.metadata,uid:t.uid}}),n):e.customers.create({email:t.email||void 0,metadata:{uid:t.uid}})}function j(e){return e?.current_period_end??e?.items?.data?.[0]?.current_period_end??null}function M(e){let t=e.items?.data?.[0],n=t?.price;return{id:e.id,status:e.status,active:S(e.status),priceId:n?.id||null,productName:typeof n?.product==`object`&&n.product?.name||null,amount:n?.unit_amount==null?null:n.unit_amount/100,currency:n?.currency||null,interval:n?.recurring?.interval||null,quantity:t?.quantity??1,currentPeriodEnd:j(e),cancelAtPeriodEnd:!!e.cancel_at_period_end}}function N(e){return{id:e.id,amount:e.amount/100,currency:e.currency,created:e.created,description:e.description||null,paid:e.paid&&e.status===`succeeded`,refunded:e.refunded,amountRefunded:e.amount_refunded/100,fullyRefunded:e.refunded&&e.amount_refunded>=e.amount}}var P=new Set;function F(e){return P.has(e)?!0:(P.add(e),P.size>500&&P.delete(P.values().next().value),!1)}function I(e={}){let n=t.default.Router();return n.post(`/payments/webhook`,t.default.raw({type:`application/json`}),async(t,n)=>{let r=(process.env.STRIPE_WEBHOOK_SECRET||``).trim(),i=t.headers[`stripe-signature`];if(!r)return E(n,503,`STRIPE_WEBHOOK_SECRET is not set.`);if(!i)return E(n,400,`Missing stripe-signature header.`);let a;try{a=g().webhooks.constructEvent(t.body,i,r)}catch(e){return E(n,400,`Webhook signature check failed: ${e.message}`)}if(F(a.id))return n.json({received:!0,duplicate:!0});try{await e.onStripeEvent?.(a)}catch(e){return P.delete(a.id),E(n,503,`Handler failed: ${e.message}`)}return n.json({received:!0})}),n.use(t.default.json()),n.get(`/_selftest`,async(e,t)=>{if(!w(e.query.token))return E(t,403,`This check needs its token.`);try{let e=await T();return t.status(e.ok?200:500).json(e)}catch(e){return D(t,e)}}),n.get(`/payments/status`,(e,t)=>{let n=h();t.json({configured:!!m(),mode:n,webhookConfigured:!!(process.env.STRIPE_WEBHOOK_SECRET||``).trim(),message:n?`Stripe is connected in ${n} mode.`:`Add STRIPE_SECRET_KEY to start taking payments.`})}),n.post(`/payments/checkout`,async(e,t)=>{let n=e.body||{},r=y(n);if(r)return E(t,400,r);let i=n.origin||e.headers.origin;if(!i)return E(t,400,`The page did not say where to send the payer back to.`);try{let r=g(),a=null,o=String(e.headers.authorization||``);if(o.startsWith(`Bearer `))try{a=await d(o.slice(7),f())}catch{a=null}let s=a?await A(r,a):null,c=x(String(i),n.successPath,n.cancelPath),l=n.priceId?{price:n.priceId,quantity:n.quantity||1}:{quantity:n.quantity||1,price_data:{currency:n.currency||`usd`,product_data:{name:String(n.productName)},unit_amount:b(Number(n.amount))}},u=await r.checkout.sessions.create({mode:n.mode||`payment`,line_items:[l],success_url:c.success,cancel_url:c.cancel,customer:s?.id,allow_promotion_codes:!0,metadata:{...n.metadata||{},...a?{uid:a.uid}:{}}});return t.json({success:!0,sessionId:u.id,url:u.url})}catch(e){return D(t,e)}}),n.get(`/payments/session/:id`,async(e,t)=>{try{let n=await g().checkout.sessions.retrieve(String(e.params.id));return t.json({success:!0,paid:n.payment_status===`paid`,paymentStatus:n.payment_status,mode:n.mode,amountTotal:n.amount_total==null?null:n.amount_total/100,currency:n.currency,customerEmail:n.customer_details?.email||null,customerName:n.customer_details?.name||null,metadata:n.metadata||{}})}catch(e){return(e instanceof Error?e.message:`Unknown error`).includes(`STRIPE_SECRET_KEY`)?D(t,e):E(t,404,`That payment could not be found.`)}}),n.get(`/payments/me`,O,async(e,t)=>{try{let n=g(),r=await k(n,e.user);if(!r)return t.json({success:!0,customerId:null,active:!1,subscriptions:[],payments:[]});let[i,a]=await Promise.all([n.subscriptions.list({customer:r.id,status:`all`,limit:10,expand:[`data.items.data.price.product`]}),n.charges.list({customer:r.id,limit:20})]),o=i.data.map(M),s=a.data.map(N);return t.json({success:!0,customerId:r.id,email:r.email,active:o.some(e=>e.active)||s.some(e=>e.paid&&!e.fullyRefunded),subscriptions:o,payments:s})}catch(e){return D(t,e)}}),n.post(`/payments/portal`,O,async(e,t)=>{try{let n=g(),r=await k(n,e.user);if(!r)return E(t,404,`There is nothing to manage on this account yet.`);let i=e.body?.origin||e.headers.origin;if(!i)return E(t,400,`The page did not say where to send them back to.`);let a=await n.billingPortal.sessions.create({customer:r.id,return_url:String(i).replace(/\/+$/,``)});return t.json({success:!0,url:a.url})}catch(e){return D(t,e)}}),n}exports.MAX_ADHOC_AMOUNT=_,exports.MIN_ADHOC_AMOUNT=v,exports.apiRoutes=I,exports.checkoutProblem=y,exports.grantsAccess=S,exports.projectId=f,exports.returnUrls=x,exports.runSelfTest=T,exports.selfTestAllowed=w,exports.stripeMode=h,exports.verifyIdToken=d;
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./chunk-DYteiM-1.cjs`);let t=require(`express`);t=e.i(t,1);let n=require(`node:crypto`),r=require(`stripe`);r=e.i(r,1);var i=`https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com`,a=60;function o(e,t,n,r=Math.floor(Date.now()/1e3)){return n?e.alg===`RS256`?e.kid?t.aud===n?t.iss===`https://securetoken.google.com/${n}`?t.sub?t.sub.length>128?`Token subject is not a valid uid.`:typeof t.exp!=`number`||t.exp<=r?`Token has expired.`:typeof t.iat!=`number`||t.iat>r+a?`Token was issued in the future.`:null:`Token has no subject.`:`Token has the wrong issuer.`:`Token was issued for a different Firebase project.`:`Token has no key id.`:`Token is not signed with RS256.`:`The server does not know its Firebase project id.`}function s(e){return Buffer.from(e.replace(/-/g,`+`).replace(/_/g,`/`),`base64`)}function c(e){let t=String(e||``).split(`.`);if(t.length!==3)return null;try{return{header:JSON.parse(s(t[0]).toString(`utf8`)),claims:JSON.parse(s(t[1]).toString(`utf8`)),signed:`${t[0]}.${t[1]}`,signature:s(t[2])}}catch{return null}}var l=null;async function u(){if(l&&l.expiresAt>Date.now())return l.keys;let e=await fetch(i);if(!e.ok)throw Error(`Could not fetch Google signing keys (${e.status}).`);let t=await e.json(),n=/max-age=(\d+)/.exec(e.headers.get(`cache-control`)||``);return l={keys:t,expiresAt:Date.now()+(n?Number(n[1]):3600)*1e3},t}async function d(e,t){let r=c(e);if(!r)throw Error(`That is not a valid sign-in token.`);let i=o(r.header,r.claims,t);if(i)throw Error(i);let a=(await u())[r.header.kid];if(!a)throw Error(`Token was signed with a key Google no longer publishes.`);let s=(0,n.createVerify)(`RSA-SHA256`);if(s.update(r.signed),s.end(),!s.verify(a,r.signature))throw Error(`Token signature does not check out.`);return{uid:r.claims.sub,email:r.claims.email||null,emailVerified:!!r.claims.email_verified}}function f(){return(process.env.VITE_FIREBASE_PROJECT_ID||process.env.FIREBASE_PROJECT_ID||``).trim()}var p=`https://firestore.googleapis.com/v1`;function m(e){return e==null?null:`stringValue`in e?e.stringValue:`integerValue`in e?Number(e.integerValue):`doubleValue`in e?e.doubleValue:`booleanValue`in e?e.booleanValue:`timestampValue`in e?e.timestampValue:`nullValue`in e?null:`mapValue`in e?h(e.mapValue?.fields||{}):`arrayValue`in e?(e.arrayValue?.values||[]).map(m):null}function h(e){let t={};for(let[n,r]of Object.entries(e||{}))t[n]=m(r);return t}function g(e,t){let n=`${e}/${t}`.split(`/`);if(n.length%2!=0)return`That is not a document path.`;for(let e of n)if(!e||e===`.`||e===`..`||!/^[A-Za-z0-9_.@~+-]+$/.test(e))return`That is not a document path.`;return null}async function _(e,t,n,r){let i=g(t,n);if(i)throw Error(i);let a=`${p}/projects/${e}/databases/(default)/documents/${t}/${n}`,o=await fetch(a,{headers:r?{Authorization:`Bearer ${r}`}:{}});if(o.status===404)return null;if(o.status===403)throw Error(`You do not have permission to read that record.`);if(!o.ok)throw Error(`Firestore refused the read (${o.status}).`);return h((await o.json()).fields||{})}var ee=[`email`,`Email`,`emailAddress`,`email_address`,`userEmail`,`contactEmail`];function v(e){for(let t of ee){let n=e[t];if(typeof n==`string`&&n.includes(`@`))return n.trim()}return null}var te=`https://api.resend.com/emails`;function y(){return(process.env.RESEND_API_KEY||``).trim()}function b(){return(process.env.EMAIL_FROM||``).trim()||`onboarding@resend.dev`}function x(){return(process.env.EMAIL_REPLY_TO||``).trim()||void 0}function S(){return(process.env.VITE_ADMIN_EMAILS||process.env.ADMIN_EMAILS||``).split(/[,\s]+/).map(e=>e.trim().toLowerCase()).filter(e=>e.includes(`@`))}var C={test:{audience:`admins`,subject:`Test email from {{appName}}`,body:`This is a test.
|
|
2
|
+
|
|
3
|
+
If you are reading it, {{appName}} can send email.
|
|
4
|
+
|
|
5
|
+
Sent {{now}}.`,description:`Proves the connection works. Goes to the admin addresses.`},new_submission:{audience:`admins`,subject:`New {{formName}} submission`,body:`Someone filled in {{formName}} on {{appName}}.
|
|
6
|
+
|
|
7
|
+
From: {{name}} ({{email}})
|
|
8
|
+
|
|
9
|
+
{{summary}}
|
|
10
|
+
|
|
11
|
+
Open the app to see the full entry.`,description:`Tells the owner a form was filled in.`},submission_received:{audience:`record`,subject:`We got your message`,body:`Hi {{name}},
|
|
12
|
+
|
|
13
|
+
Thanks for getting in touch — we have your message and will come back to you shortly.
|
|
14
|
+
|
|
15
|
+
— {{appName}}`,description:`Confirms to the sender that their form arrived.`},order_confirmed:{audience:`record`,subject:`Your order is confirmed`,body:`Hi {{name}},
|
|
16
|
+
|
|
17
|
+
Thank you — your payment of {{amount}} went through and your order is confirmed.
|
|
18
|
+
|
|
19
|
+
{{details}}
|
|
20
|
+
|
|
21
|
+
— {{appName}}`,description:`Sent to the buyer after a successful payment.`},new_sale:{audience:`admins`,subject:`You made a sale — {{amount}}`,body:`{{name}} ({{email}}) just paid {{amount}} on {{appName}}.
|
|
22
|
+
|
|
23
|
+
{{details}}`,description:`Tells the owner money came in.`}};function w(e){return e?Object.prototype.hasOwnProperty.call(C,e)?null:`There is no email template called "${e}". Available: ${Object.keys(C).join(`, `)}.`:`No template was named.`}function T(e){return String(e).replace(/&/g,`&`).replace(/</g,`<`).replace(/>/g,`>`).replace(/"/g,`"`).replace(/'/g,`'`)}function E(e,t,n){return String(e).replace(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g,(e,r)=>{let i=t[r];return i==null?``:n?T(String(i)):String(i)})}function D(e,t){let n=E(e.body,t,!1);return{subject:E(e.subject,t,!1).slice(0,200),text:n,html:`<div style="font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:15px;line-height:1.6;color:#1e293b">`+E(e.body,t,!0).split(`
|
|
24
|
+
|
|
25
|
+
`).map(e=>`<p>${e.replace(/\n/g,`<br>`)}</p>`).join(``)+`</div>`}}async function O(e,t){let n=y();if(!n)return{id:null,error:`RESEND_API_KEY is not set.`};if(!e.length)return{id:null,error:`There is nobody to send this to.`};let r=await fetch(te,{method:`POST`,headers:{Authorization:`Bearer ${n}`,"Content-Type":`application/json`},body:JSON.stringify({from:b(),to:e,reply_to:x(),subject:t.subject,html:t.html,text:t.text})}),i=await r.json().catch(()=>({}));return r.ok?{id:i?.id||null,error:null}:{id:null,error:i?.message||`Resend refused the send (${r.status}).`}}var k=null;function A(){return(process.env.STRIPE_SECRET_KEY||``).trim()}function j(){let e=A();return e?e.startsWith(`sk_live_`)?`live`:`test`:null}function M(){let e=A();if(!e)throw Error(`STRIPE_SECRET_KEY is not set.`);return k||=new r.default(e),k}var N=Number(process.env.STRIPE_MAX_ADHOC_AMOUNT||1e4),P=.5;function F(e){let{priceId:t,amount:n,mode:r=`payment`,quantity:i=1}=e||{};if(!t&&n===void 0)return`This button does not say what is being sold.`;if(t&&n!==void 0)return`A payment has either a Stripe price or an amount, not both.`;if(r!==`payment`&&r!==`subscription`)return`A payment is either one-time or a subscription.`;if(!Number.isInteger(i)||i<1||i>999)return`Quantity must be a whole number between 1 and 999.`;if(n!==void 0){if(r===`subscription`)return`A subscription needs a price created in Stripe — a typed amount cannot recur.`;if(typeof n!=`number`||!Number.isFinite(n))return`That amount is not a number.`;if(n<.5)return`The smallest amount that can be charged is ${P}.`;if(n>N)return`That amount is above the ${N} limit for typed prices. Use a price created in Stripe.`;if(!e.productName||!String(e.productName).trim())return`A typed amount needs a name for what is being bought.`}return t&&!/^price_[A-Za-z0-9]+$/.test(t)?`That does not look like a Stripe price id.`:null}function I(e){return Math.round(e*100)}function L(e,t,n){let r=String(e||``).replace(/\/+$/,``),i=(e,t)=>{let n=String(e||t);return n.startsWith(`/`)?n:`/${n}`};return{success:`${r}${i(t,`/payment-complete`)}?session_id={CHECKOUT_SESSION_ID}`,cancel:`${r}${i(n,`/payment-cancelled`)}`}}function R(e){return e===`active`||e===`trialing`||e===`past_due`}var z=e=>!!(process.env[e]||``).trim();function B(e){let t=(process.env.SELFTEST_TOKEN||``).trim();return t?String(e||``)===t:!0}async function V(){let e=[{name:`Server`,status:`pass`,detail:`The backend is running and reachable.`}],t=f();if(!t)e.push({name:`Firebase project`,status:`fail`,detail:`VITE_FIREBASE_PROJECT_ID is not set, so the server cannot tell who is signed in.`});else{e.push({name:`Firebase project`,status:`pass`,detail:`Signing in against ${t}.`});try{let t=await u();e.push({name:`Sign-in verification`,status:`pass`,detail:`Google's signing keys are reachable (${Object.keys(t).length} in rotation).`})}catch(t){e.push({name:`Sign-in verification`,status:`fail`,detail:`Could not reach Google's signing keys: ${t.message}`})}}if(!A())e.push({name:`Stripe`,status:`warn`,detail:`STRIPE_SECRET_KEY is not set. Payments are switched off until it is.`});else{let t=j();try{let n=await M().accounts.retrieve();e.push({name:`Stripe`,status:`pass`,detail:`Connected in ${t} mode to ${n.business_profile?.name||n.id}.`}),t===`live`&&e.push({name:`Live mode`,status:`warn`,detail:`This app is charging real cards. Every payment made here is real money.`})}catch(t){e.push({name:`Stripe`,status:`fail`,detail:`The key is set but Stripe refused it: ${t.message}`})}e.push(z(`STRIPE_WEBHOOK_SECRET`)?{name:`Stripe webhook`,status:`pass`,detail:`Signed webhooks will be accepted.`}:{name:`Stripe webhook`,status:`warn`,detail:`STRIPE_WEBHOOK_SECRET is not set. Payments still confirm when the payer returns; refunds and cancellations made in the Stripe dashboard will not reach the app until it is.`})}return e.push(z(`RESEND_API_KEY`)?{name:`Email`,status:`pass`,detail:`RESEND_API_KEY is set.`}:{name:`Email`,status:`warn`,detail:`RESEND_API_KEY is not set. The app will not send its own email.`}),{ok:!e.some(e=>e.status===`fail`),service:`your-ai-workflow-firebase-os`,checks:e}}var H=(e,t,n,r={})=>e.status(t).json({success:!1,error:n,...r});function U(e,t){let n=t instanceof Error?t.message:`Something went wrong on the server.`,r=n.includes(`STRIPE_SECRET_KEY`)||n.includes(`project id`);return H(e,r?503:500,n,r?{requiresSetup:!0}:{})}async function W(e,t,n){let r=String(e.headers.authorization||``),i=r.startsWith(`Bearer `)?r.slice(7):``;if(!i)return H(t,401,`Please sign in first.`);try{e.user=await d(i,f()),e.idToken=i,n()}catch(e){return H(t,401,e.message)}}async function G(e,t){return!t.email||!t.emailVerified?null:(await e.customers.list({email:t.email,limit:1})).data[0]||null}async function K(e,t){let n=await G(e,t);return n?(n.metadata?.uid!==t.uid&&await e.customers.update(n.id,{metadata:{...n.metadata,uid:t.uid}}),n):e.customers.create({email:t.email||void 0,metadata:{uid:t.uid}})}function q(e){return e?.current_period_end??e?.items?.data?.[0]?.current_period_end??null}function J(e){let t=e.items?.data?.[0],n=t?.price;return{id:e.id,status:e.status,active:R(e.status),priceId:n?.id||null,productName:typeof n?.product==`object`&&n.product?.name||null,amount:n?.unit_amount==null?null:n.unit_amount/100,currency:n?.currency||null,interval:n?.recurring?.interval||null,quantity:t?.quantity??1,currentPeriodEnd:q(e),cancelAtPeriodEnd:!!e.cancel_at_period_end}}function ne(e){return{id:e.id,amount:e.amount/100,currency:e.currency,created:e.created,description:e.description||null,paid:e.paid&&e.status===`succeeded`,refunded:e.refunded,amountRefunded:e.amount_refunded/100,fullyRefunded:e.refunded&&e.amount_refunded>=e.amount}}var re=600*1e3,ie=Number(process.env.EMAIL_RATE_PER_CALLER||20),ae=Number(process.env.EMAIL_RATE_PER_HOUR||200),Y=new Map,X=[];function Z(e){let t=Date.now();for(;X.length&&t-X[0]>3600*1e3;)X.shift();if(X.length>=ae)return`This app has sent its hourly limit of email. Try again shortly.`;let n=(Y.get(e)||[]).filter(e=>t-e<re);return n.length>=ie?(Y.set(e,n),`Too many messages from here just now. Try again in a few minutes.`):(n.push(t),Y.set(e,n),X.push(t),Y.size>5e3&&Y.clear(),null)}var Q=e=>String(e.ip||e.socket?.remoteAddress||`unknown`),$=new Set;function oe(e){return $.has(e)?!0:($.add(e),$.size>500&&$.delete($.values().next().value),!1)}function se(e={}){let n=t.default.Router();return n.post(`/payments/webhook`,t.default.raw({type:`application/json`}),async(t,n)=>{let r=(process.env.STRIPE_WEBHOOK_SECRET||``).trim(),i=t.headers[`stripe-signature`];if(!r)return H(n,503,`STRIPE_WEBHOOK_SECRET is not set.`);if(!i)return H(n,400,`Missing stripe-signature header.`);let a;try{a=M().webhooks.constructEvent(t.body,i,r)}catch(e){return H(n,400,`Webhook signature check failed: ${e.message}`)}if(oe(a.id))return n.json({received:!0,duplicate:!0});try{await e.onStripeEvent?.(a)}catch(e){return $.delete(a.id),H(n,503,`Handler failed: ${e.message}`)}return n.json({received:!0})}),n.use(t.default.json()),n.get(`/_selftest`,async(e,t)=>{if(!B(e.query.token))return H(t,403,`This check needs its token.`);try{let e=await V();return t.status(e.ok?200:500).json(e)}catch(e){return U(t,e)}}),n.get(`/_selftest/checkout`,async(e,t)=>{if(!B(e.query.token))return H(t,403,`This check needs its token.`);if(j()!==`test`)return H(t,403,`The test checkout only runs against a test key (sk_test_).`);try{let n=e.headers.origin||`${e.protocol}://${e.get(`host`)}`,r=L(String(n),`/api/_selftest/paid`,`/api/_selftest/cancelled`),i=await M().checkout.sessions.create({mode:`payment`,line_items:[{quantity:1,price_data:{currency:`usd`,product_data:{name:`Self-test payment`},unit_amount:100}}],success_url:r.success,cancel_url:r.cancel,metadata:{selftest:`true`}});return t.redirect(303,i.url)}catch(e){return U(t,e)}}),n.get(`/_selftest/paid`,async(e,t)=>{let n=String(e.query.session_id||``);if(!n)return H(t,400,`No session id came back.`);try{let e=await M().checkout.sessions.retrieve(n),r=e.payment_status===`paid`;return t.status(r?200:402).json({ok:r,message:r?`The whole loop works: checkout created, card charged, payment confirmed by the server.`:`Stripe says this payment is ${e.payment_status}.`,amount:e.amount_total==null?null:e.amount_total/100,currency:e.currency,email:e.customer_details?.email||null})}catch(e){return U(t,e)}}),n.get(`/_selftest/cancelled`,(e,t)=>t.json({ok:!0,message:`Payment cancelled — nothing was charged.`})),n.get(`/_selftest/email`,async(e,t)=>{if(!B(e.query.token))return H(t,403,`This check needs its token.`);if(!y())return H(t,503,`RESEND_API_KEY is not set.`);let n=S();if(!n.length)return H(t,503,`VITE_ADMIN_EMAILS is empty, so there is no admin address to send to.`);let r=await O(n,D(C.test,{appName:process.env.APP_NAME||`your app`,now:new Date().toISOString()}));return t.status(r.error?502:200).json({ok:!r.error,sentTo:n,from:b(),id:r.id,error:r.error,message:r.error?`Resend refused the send.`:`Sent. If it does not arrive within a minute, check spam — an unverified domain often lands there.`})}),n.get(`/email/status`,(e,t)=>{t.json({configured:!!y(),from:b(),adminCount:S().length,templates:Object.entries(C).map(([e,t])=>({id:e,audience:t.audience,description:t.description}))})}),n.post(`/email/notify`,async(e,t)=>{let n=Z(Q(e));if(n)return H(t,429,n);let{template:r,data:i}=e.body||{},a=w(String(r||``));if(a)return H(t,400,a);let o=C[String(r)];if(o.audience!==`admins`)return H(t,400,`The "${r}" template is written to a customer, not to you.`);let s=S();if(!s.length)return H(t,503,`There is no admin address to send to.`);let c=await O(s,D(o,{appName:process.env.APP_NAME||`your app`,...i||{}}));return t.status(c.error?502:200).json({success:!c.error,id:c.id,error:c.error})}),n.post(`/email/send`,async(e,t)=>{let n=Z(Q(e));if(n)return H(t,429,n);let{template:r,collection:i,docId:a,data:o}=e.body||{},s=w(String(r||``));if(s)return H(t,400,s);if(!i||!a)return H(t,400,`Name the record this email is about.`);let c=C[String(r)];if(c.audience!==`record`)return H(t,400,`The "${r}" template is written to you, not to a customer. Use /email/notify.`);try{let n=String(e.headers.authorization||``),r=n.startsWith(`Bearer `)?n.slice(7):void 0,s=await _(f(),String(i),String(a),r);if(!s)return H(t,404,`That record does not exist.`);let l=v(s);if(!l)return H(t,422,`That record has no email address on it.`);let u=D(c,{appName:process.env.APP_NAME||`your app`,...s,...o||{}}),d=await O([l],u);return t.status(d.error?502:200).json({success:!d.error,id:d.id,error:d.error})}catch(e){return H(t,400,e.message)}}),n.get(`/payments/status`,(e,t)=>{let n=j();t.json({configured:!!A(),mode:n,webhookConfigured:!!(process.env.STRIPE_WEBHOOK_SECRET||``).trim(),message:n?`Stripe is connected in ${n} mode.`:`Add STRIPE_SECRET_KEY to start taking payments.`})}),n.post(`/payments/checkout`,async(e,t)=>{let n=e.body||{},r=F(n);if(r)return H(t,400,r);let i=n.origin||e.headers.origin;if(!i)return H(t,400,`The page did not say where to send the payer back to.`);try{let r=M(),a=null,o=String(e.headers.authorization||``);if(o.startsWith(`Bearer `))try{a=await d(o.slice(7),f())}catch{a=null}let s=a?await K(r,a):null,c=L(String(i),n.successPath,n.cancelPath),l=n.priceId?{price:n.priceId,quantity:n.quantity||1}:{quantity:n.quantity||1,price_data:{currency:n.currency||`usd`,product_data:{name:String(n.productName)},unit_amount:I(Number(n.amount))}},u=await r.checkout.sessions.create({mode:n.mode||`payment`,line_items:[l],success_url:c.success,cancel_url:c.cancel,customer:s?.id,allow_promotion_codes:!0,metadata:{...n.metadata||{},...a?{uid:a.uid}:{}}});return t.json({success:!0,sessionId:u.id,url:u.url})}catch(e){return U(t,e)}}),n.get(`/payments/session/:id`,async(e,t)=>{try{let n=await M().checkout.sessions.retrieve(String(e.params.id));return t.json({success:!0,paid:n.payment_status===`paid`,paymentStatus:n.payment_status,mode:n.mode,amountTotal:n.amount_total==null?null:n.amount_total/100,currency:n.currency,customerEmail:n.customer_details?.email||null,customerName:n.customer_details?.name||null,metadata:n.metadata||{}})}catch(e){return(e instanceof Error?e.message:`Unknown error`).includes(`STRIPE_SECRET_KEY`)?U(t,e):H(t,404,`That payment could not be found.`)}}),n.get(`/payments/me`,W,async(e,t)=>{try{let n=M(),r=await G(n,e.user);if(!r)return t.json({success:!0,customerId:null,active:!1,subscriptions:[],payments:[]});let[i,a]=await Promise.all([n.subscriptions.list({customer:r.id,status:`all`,limit:10,expand:[`data.items.data.price.product`]}),n.charges.list({customer:r.id,limit:20})]),o=i.data.map(J),s=a.data.map(ne);return t.json({success:!0,customerId:r.id,email:r.email,active:o.some(e=>e.active)||s.some(e=>e.paid&&!e.fullyRefunded),subscriptions:o,payments:s})}catch(e){return U(t,e)}}),n.post(`/payments/portal`,W,async(e,t)=>{try{let n=M(),r=await G(n,e.user);if(!r)return H(t,404,`There is nothing to manage on this account yet.`);let i=e.body?.origin||e.headers.origin;if(!i)return H(t,400,`The page did not say where to send them back to.`);let a=await n.billingPortal.sessions.create({customer:r.id,return_url:String(i).replace(/\/+$/,``)});return t.json({success:!0,url:a.url})}catch(e){return U(t,e)}}),n}exports.MAX_ADHOC_AMOUNT=N,exports.MIN_ADHOC_AMOUNT=P,exports.TEMPLATES=C,exports.adminEmails=S,exports.apiRoutes=se,exports.checkoutProblem=F,exports.emailOnRecord=v,exports.escapeHtml=T,exports.fill=E,exports.fromFields=h,exports.grantsAccess=R,exports.pathProblem=g,exports.projectId=f,exports.readDocument=_,exports.renderTemplate=D,exports.returnUrls=L,exports.runSelfTest=V,exports.selfTestAllowed=B,exports.stripeMode=j,exports.verifyIdToken=d;
|
package/dist/server.es.js
CHANGED
|
@@ -53,22 +53,163 @@ function d() {
|
|
|
53
53
|
return (process.env.VITE_FIREBASE_PROJECT_ID || process.env.FIREBASE_PROJECT_ID || "").trim();
|
|
54
54
|
}
|
|
55
55
|
//#endregion
|
|
56
|
+
//#region src/server/firestore.ts
|
|
57
|
+
var f = "https://firestore.googleapis.com/v1";
|
|
58
|
+
function p(e) {
|
|
59
|
+
return e == null ? null : "stringValue" in e ? e.stringValue : "integerValue" in e ? Number(e.integerValue) : "doubleValue" in e ? e.doubleValue : "booleanValue" in e ? e.booleanValue : "timestampValue" in e ? e.timestampValue : "nullValue" in e ? null : "mapValue" in e ? m(e.mapValue?.fields || {}) : "arrayValue" in e ? (e.arrayValue?.values || []).map(p) : null;
|
|
60
|
+
}
|
|
61
|
+
function m(e) {
|
|
62
|
+
let t = {};
|
|
63
|
+
for (let [n, r] of Object.entries(e || {})) t[n] = p(r);
|
|
64
|
+
return t;
|
|
65
|
+
}
|
|
66
|
+
function h(e, t) {
|
|
67
|
+
let n = `${e}/${t}`.split("/");
|
|
68
|
+
if (n.length % 2 != 0) return "That is not a document path.";
|
|
69
|
+
for (let e of n) if (!e || e === "." || e === ".." || !/^[A-Za-z0-9_.@~+-]+$/.test(e)) return "That is not a document path.";
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
async function g(e, t, n, r) {
|
|
73
|
+
let i = h(t, n);
|
|
74
|
+
if (i) throw Error(i);
|
|
75
|
+
let a = `${f}/projects/${e}/databases/(default)/documents/${t}/${n}`, o = await fetch(a, { headers: r ? { Authorization: `Bearer ${r}` } : {} });
|
|
76
|
+
if (o.status === 404) return null;
|
|
77
|
+
if (o.status === 403) throw Error("You do not have permission to read that record.");
|
|
78
|
+
if (!o.ok) throw Error(`Firestore refused the read (${o.status}).`);
|
|
79
|
+
return m((await o.json()).fields || {});
|
|
80
|
+
}
|
|
81
|
+
var ee = [
|
|
82
|
+
"email",
|
|
83
|
+
"Email",
|
|
84
|
+
"emailAddress",
|
|
85
|
+
"email_address",
|
|
86
|
+
"userEmail",
|
|
87
|
+
"contactEmail"
|
|
88
|
+
];
|
|
89
|
+
function _(e) {
|
|
90
|
+
for (let t of ee) {
|
|
91
|
+
let n = e[t];
|
|
92
|
+
if (typeof n == "string" && n.includes("@")) return n.trim();
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/server/email.ts
|
|
98
|
+
var te = "https://api.resend.com/emails";
|
|
99
|
+
function v() {
|
|
100
|
+
return (process.env.RESEND_API_KEY || "").trim();
|
|
101
|
+
}
|
|
102
|
+
function y() {
|
|
103
|
+
return (process.env.EMAIL_FROM || "").trim() || "onboarding@resend.dev";
|
|
104
|
+
}
|
|
105
|
+
function b() {
|
|
106
|
+
return (process.env.EMAIL_REPLY_TO || "").trim() || void 0;
|
|
107
|
+
}
|
|
108
|
+
function x() {
|
|
109
|
+
return (process.env.VITE_ADMIN_EMAILS || process.env.ADMIN_EMAILS || "").split(/[,\s]+/).map((e) => e.trim().toLowerCase()).filter((e) => e.includes("@"));
|
|
110
|
+
}
|
|
111
|
+
var S = {
|
|
112
|
+
test: {
|
|
113
|
+
audience: "admins",
|
|
114
|
+
subject: "Test email from {{appName}}",
|
|
115
|
+
body: "This is a test.\n\nIf you are reading it, {{appName}} can send email.\n\nSent {{now}}.",
|
|
116
|
+
description: "Proves the connection works. Goes to the admin addresses."
|
|
117
|
+
},
|
|
118
|
+
new_submission: {
|
|
119
|
+
audience: "admins",
|
|
120
|
+
subject: "New {{formName}} submission",
|
|
121
|
+
body: "Someone filled in {{formName}} on {{appName}}.\n\nFrom: {{name}} ({{email}})\n\n{{summary}}\n\nOpen the app to see the full entry.",
|
|
122
|
+
description: "Tells the owner a form was filled in."
|
|
123
|
+
},
|
|
124
|
+
submission_received: {
|
|
125
|
+
audience: "record",
|
|
126
|
+
subject: "We got your message",
|
|
127
|
+
body: "Hi {{name}},\n\nThanks for getting in touch — we have your message and will come back to you shortly.\n\n— {{appName}}",
|
|
128
|
+
description: "Confirms to the sender that their form arrived."
|
|
129
|
+
},
|
|
130
|
+
order_confirmed: {
|
|
131
|
+
audience: "record",
|
|
132
|
+
subject: "Your order is confirmed",
|
|
133
|
+
body: "Hi {{name}},\n\nThank you — your payment of {{amount}} went through and your order is confirmed.\n\n{{details}}\n\n— {{appName}}",
|
|
134
|
+
description: "Sent to the buyer after a successful payment."
|
|
135
|
+
},
|
|
136
|
+
new_sale: {
|
|
137
|
+
audience: "admins",
|
|
138
|
+
subject: "You made a sale — {{amount}}",
|
|
139
|
+
body: "{{name}} ({{email}}) just paid {{amount}} on {{appName}}.\n\n{{details}}",
|
|
140
|
+
description: "Tells the owner money came in."
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
function C(e) {
|
|
144
|
+
return e ? Object.prototype.hasOwnProperty.call(S, e) ? null : `There is no email template called "${e}". Available: ${Object.keys(S).join(", ")}.` : "No template was named.";
|
|
145
|
+
}
|
|
146
|
+
function w(e) {
|
|
147
|
+
return String(e).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
148
|
+
}
|
|
149
|
+
function T(e, t, n) {
|
|
150
|
+
return String(e).replace(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g, (e, r) => {
|
|
151
|
+
let i = t[r];
|
|
152
|
+
return i == null ? "" : n ? w(String(i)) : String(i);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function E(e, t) {
|
|
156
|
+
let n = T(e.body, t, !1);
|
|
157
|
+
return {
|
|
158
|
+
subject: T(e.subject, t, !1).slice(0, 200),
|
|
159
|
+
text: n,
|
|
160
|
+
html: "<div style=\"font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:15px;line-height:1.6;color:#1e293b\">" + T(e.body, t, !0).split("\n\n").map((e) => `<p>${e.replace(/\n/g, "<br>")}</p>`).join("") + "</div>"
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
async function D(e, t) {
|
|
164
|
+
let n = v();
|
|
165
|
+
if (!n) return {
|
|
166
|
+
id: null,
|
|
167
|
+
error: "RESEND_API_KEY is not set."
|
|
168
|
+
};
|
|
169
|
+
if (!e.length) return {
|
|
170
|
+
id: null,
|
|
171
|
+
error: "There is nobody to send this to."
|
|
172
|
+
};
|
|
173
|
+
let r = await fetch(te, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: {
|
|
176
|
+
Authorization: `Bearer ${n}`,
|
|
177
|
+
"Content-Type": "application/json"
|
|
178
|
+
},
|
|
179
|
+
body: JSON.stringify({
|
|
180
|
+
from: y(),
|
|
181
|
+
to: e,
|
|
182
|
+
reply_to: b(),
|
|
183
|
+
subject: t.subject,
|
|
184
|
+
html: t.html,
|
|
185
|
+
text: t.text
|
|
186
|
+
})
|
|
187
|
+
}), i = await r.json().catch(() => ({}));
|
|
188
|
+
return r.ok ? {
|
|
189
|
+
id: i?.id || null,
|
|
190
|
+
error: null
|
|
191
|
+
} : {
|
|
192
|
+
id: null,
|
|
193
|
+
error: i?.message || `Resend refused the send (${r.status}).`
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
56
197
|
//#region src/server/stripeClient.ts
|
|
57
|
-
var
|
|
58
|
-
function
|
|
198
|
+
var O = null;
|
|
199
|
+
function k() {
|
|
59
200
|
return (process.env.STRIPE_SECRET_KEY || "").trim();
|
|
60
201
|
}
|
|
61
|
-
function
|
|
62
|
-
let e =
|
|
202
|
+
function A() {
|
|
203
|
+
let e = k();
|
|
63
204
|
return e ? e.startsWith("sk_live_") ? "live" : "test" : null;
|
|
64
205
|
}
|
|
65
|
-
function
|
|
66
|
-
let e =
|
|
206
|
+
function j() {
|
|
207
|
+
let e = k();
|
|
67
208
|
if (!e) throw Error("STRIPE_SECRET_KEY is not set.");
|
|
68
|
-
return
|
|
209
|
+
return O ||= new n(e), O;
|
|
69
210
|
}
|
|
70
|
-
var
|
|
71
|
-
function
|
|
211
|
+
var M = Number(process.env.STRIPE_MAX_ADHOC_AMOUNT || 1e4), N = .5;
|
|
212
|
+
function P(e) {
|
|
72
213
|
let { priceId: t, amount: n, mode: r = "payment", quantity: i = 1 } = e || {};
|
|
73
214
|
if (!t && n === void 0) return "This button does not say what is being sold.";
|
|
74
215
|
if (t && n !== void 0) return "A payment has either a Stripe price or an amount, not both.";
|
|
@@ -77,16 +218,16 @@ function v(e) {
|
|
|
77
218
|
if (n !== void 0) {
|
|
78
219
|
if (r === "subscription") return "A subscription needs a price created in Stripe — a typed amount cannot recur.";
|
|
79
220
|
if (typeof n != "number" || !Number.isFinite(n)) return "That amount is not a number.";
|
|
80
|
-
if (n < .5) return `The smallest amount that can be charged is ${
|
|
81
|
-
if (n >
|
|
221
|
+
if (n < .5) return `The smallest amount that can be charged is ${N}.`;
|
|
222
|
+
if (n > M) return `That amount is above the ${M} limit for typed prices. Use a price created in Stripe.`;
|
|
82
223
|
if (!e.productName || !String(e.productName).trim()) return "A typed amount needs a name for what is being bought.";
|
|
83
224
|
}
|
|
84
225
|
return t && !/^price_[A-Za-z0-9]+$/.test(t) ? "That does not look like a Stripe price id." : null;
|
|
85
226
|
}
|
|
86
|
-
function
|
|
227
|
+
function F(e) {
|
|
87
228
|
return Math.round(e * 100);
|
|
88
229
|
}
|
|
89
|
-
function
|
|
230
|
+
function I(e, t, n) {
|
|
90
231
|
let r = String(e || "").replace(/\/+$/, ""), i = (e, t) => {
|
|
91
232
|
let n = String(e || t);
|
|
92
233
|
return n.startsWith("/") ? n : `/${n}`;
|
|
@@ -96,17 +237,17 @@ function b(e, t, n) {
|
|
|
96
237
|
cancel: `${r}${i(n, "/payment-cancelled")}`
|
|
97
238
|
};
|
|
98
239
|
}
|
|
99
|
-
function
|
|
240
|
+
function L(e) {
|
|
100
241
|
return e === "active" || e === "trialing" || e === "past_due";
|
|
101
242
|
}
|
|
102
243
|
//#endregion
|
|
103
244
|
//#region src/server/selftest.ts
|
|
104
|
-
var
|
|
105
|
-
function
|
|
245
|
+
var R = (e) => !!(process.env[e] || "").trim();
|
|
246
|
+
function z(e) {
|
|
106
247
|
let t = (process.env.SELFTEST_TOKEN || "").trim();
|
|
107
248
|
return t ? String(e || "") === t : !0;
|
|
108
249
|
}
|
|
109
|
-
async function
|
|
250
|
+
async function B() {
|
|
110
251
|
let e = [{
|
|
111
252
|
name: "Server",
|
|
112
253
|
status: "pass",
|
|
@@ -138,15 +279,15 @@ async function w() {
|
|
|
138
279
|
});
|
|
139
280
|
}
|
|
140
281
|
}
|
|
141
|
-
if (!
|
|
282
|
+
if (!k()) e.push({
|
|
142
283
|
name: "Stripe",
|
|
143
284
|
status: "warn",
|
|
144
285
|
detail: "STRIPE_SECRET_KEY is not set. Payments are switched off until it is."
|
|
145
286
|
});
|
|
146
287
|
else {
|
|
147
|
-
let t =
|
|
288
|
+
let t = A();
|
|
148
289
|
try {
|
|
149
|
-
let n = await
|
|
290
|
+
let n = await j().accounts.retrieve();
|
|
150
291
|
e.push({
|
|
151
292
|
name: "Stripe",
|
|
152
293
|
status: "pass",
|
|
@@ -163,7 +304,7 @@ async function w() {
|
|
|
163
304
|
detail: `The key is set but Stripe refused it: ${t.message}`
|
|
164
305
|
});
|
|
165
306
|
}
|
|
166
|
-
e.push(
|
|
307
|
+
e.push(R("STRIPE_WEBHOOK_SECRET") ? {
|
|
167
308
|
name: "Stripe webhook",
|
|
168
309
|
status: "pass",
|
|
169
310
|
detail: "Signed webhooks will be accepted."
|
|
@@ -173,7 +314,7 @@ async function w() {
|
|
|
173
314
|
detail: "STRIPE_WEBHOOK_SECRET is not set. Payments still confirm when the payer returns; refunds and cancellations made in the Stripe dashboard will not reach the app until it is."
|
|
174
315
|
});
|
|
175
316
|
}
|
|
176
|
-
return e.push(
|
|
317
|
+
return e.push(R("RESEND_API_KEY") ? {
|
|
177
318
|
name: "Email",
|
|
178
319
|
status: "pass",
|
|
179
320
|
detail: "RESEND_API_KEY is set."
|
|
@@ -189,32 +330,32 @@ async function w() {
|
|
|
189
330
|
}
|
|
190
331
|
//#endregion
|
|
191
332
|
//#region src/server/index.ts
|
|
192
|
-
var
|
|
333
|
+
var V = (e, t, n, r = {}) => e.status(t).json({
|
|
193
334
|
success: !1,
|
|
194
335
|
error: n,
|
|
195
336
|
...r
|
|
196
337
|
});
|
|
197
|
-
function
|
|
338
|
+
function H(e, t) {
|
|
198
339
|
let n = t instanceof Error ? t.message : "Something went wrong on the server.", r = n.includes("STRIPE_SECRET_KEY") || n.includes("project id");
|
|
199
|
-
return
|
|
340
|
+
return V(e, r ? 503 : 500, n, r ? { requiresSetup: !0 } : {});
|
|
200
341
|
}
|
|
201
|
-
async function
|
|
342
|
+
async function U(e, t, n) {
|
|
202
343
|
let r = String(e.headers.authorization || ""), i = r.startsWith("Bearer ") ? r.slice(7) : "";
|
|
203
|
-
if (!i) return
|
|
344
|
+
if (!i) return V(t, 401, "Please sign in first.");
|
|
204
345
|
try {
|
|
205
|
-
e.user = await u(i, d()), n();
|
|
346
|
+
e.user = await u(i, d()), e.idToken = i, n();
|
|
206
347
|
} catch (e) {
|
|
207
|
-
return
|
|
348
|
+
return V(t, 401, e.message);
|
|
208
349
|
}
|
|
209
350
|
}
|
|
210
|
-
async function
|
|
351
|
+
async function W(e, t) {
|
|
211
352
|
return !t.email || !t.emailVerified ? null : (await e.customers.list({
|
|
212
353
|
email: t.email,
|
|
213
354
|
limit: 1
|
|
214
355
|
})).data[0] || null;
|
|
215
356
|
}
|
|
216
|
-
async function
|
|
217
|
-
let n = await
|
|
357
|
+
async function G(e, t) {
|
|
358
|
+
let n = await W(e, t);
|
|
218
359
|
return n ? (n.metadata?.uid !== t.uid && await e.customers.update(n.id, { metadata: {
|
|
219
360
|
...n.metadata,
|
|
220
361
|
uid: t.uid
|
|
@@ -223,26 +364,26 @@ async function k(e, t) {
|
|
|
223
364
|
metadata: { uid: t.uid }
|
|
224
365
|
});
|
|
225
366
|
}
|
|
226
|
-
function
|
|
367
|
+
function K(e) {
|
|
227
368
|
return e?.current_period_end ?? e?.items?.data?.[0]?.current_period_end ?? null;
|
|
228
369
|
}
|
|
229
|
-
function
|
|
370
|
+
function q(e) {
|
|
230
371
|
let t = e.items?.data?.[0], n = t?.price;
|
|
231
372
|
return {
|
|
232
373
|
id: e.id,
|
|
233
374
|
status: e.status,
|
|
234
|
-
active:
|
|
375
|
+
active: L(e.status),
|
|
235
376
|
priceId: n?.id || null,
|
|
236
377
|
productName: typeof n?.product == "object" && n.product?.name || null,
|
|
237
378
|
amount: n?.unit_amount == null ? null : n.unit_amount / 100,
|
|
238
379
|
currency: n?.currency || null,
|
|
239
380
|
interval: n?.recurring?.interval || null,
|
|
240
381
|
quantity: t?.quantity ?? 1,
|
|
241
|
-
currentPeriodEnd:
|
|
382
|
+
currentPeriodEnd: K(e),
|
|
242
383
|
cancelAtPeriodEnd: !!e.cancel_at_period_end
|
|
243
384
|
};
|
|
244
385
|
}
|
|
245
|
-
function
|
|
386
|
+
function J(e) {
|
|
246
387
|
return {
|
|
247
388
|
id: e.id,
|
|
248
389
|
amount: e.amount / 100,
|
|
@@ -255,61 +396,181 @@ function M(e) {
|
|
|
255
396
|
fullyRefunded: e.refunded && e.amount_refunded >= e.amount
|
|
256
397
|
};
|
|
257
398
|
}
|
|
258
|
-
var
|
|
259
|
-
function
|
|
260
|
-
|
|
399
|
+
var ne = 600 * 1e3, re = Number(process.env.EMAIL_RATE_PER_CALLER || 20), ie = Number(process.env.EMAIL_RATE_PER_HOUR || 200), Y = /* @__PURE__ */ new Map(), X = [];
|
|
400
|
+
function Z(e) {
|
|
401
|
+
let t = Date.now();
|
|
402
|
+
for (; X.length && t - X[0] > 3600 * 1e3;) X.shift();
|
|
403
|
+
if (X.length >= ie) return "This app has sent its hourly limit of email. Try again shortly.";
|
|
404
|
+
let n = (Y.get(e) || []).filter((e) => t - e < ne);
|
|
405
|
+
return n.length >= re ? (Y.set(e, n), "Too many messages from here just now. Try again in a few minutes.") : (n.push(t), Y.set(e, n), X.push(t), Y.size > 5e3 && Y.clear(), null);
|
|
406
|
+
}
|
|
407
|
+
var Q = (e) => String(e.ip || e.socket?.remoteAddress || "unknown"), $ = /* @__PURE__ */ new Set();
|
|
408
|
+
function ae(e) {
|
|
409
|
+
return $.has(e) ? !0 : ($.add(e), $.size > 500 && $.delete($.values().next().value), !1);
|
|
261
410
|
}
|
|
262
|
-
function
|
|
411
|
+
function oe(t = {}) {
|
|
263
412
|
let n = e.Router();
|
|
264
413
|
return n.post("/payments/webhook", e.raw({ type: "application/json" }), async (e, n) => {
|
|
265
414
|
let r = (process.env.STRIPE_WEBHOOK_SECRET || "").trim(), i = e.headers["stripe-signature"];
|
|
266
|
-
if (!r) return
|
|
267
|
-
if (!i) return
|
|
415
|
+
if (!r) return V(n, 503, "STRIPE_WEBHOOK_SECRET is not set.");
|
|
416
|
+
if (!i) return V(n, 400, "Missing stripe-signature header.");
|
|
268
417
|
let a;
|
|
269
418
|
try {
|
|
270
|
-
a =
|
|
419
|
+
a = j().webhooks.constructEvent(e.body, i, r);
|
|
271
420
|
} catch (e) {
|
|
272
|
-
return
|
|
421
|
+
return V(n, 400, `Webhook signature check failed: ${e.message}`);
|
|
273
422
|
}
|
|
274
|
-
if (
|
|
423
|
+
if (ae(a.id)) return n.json({
|
|
275
424
|
received: !0,
|
|
276
425
|
duplicate: !0
|
|
277
426
|
});
|
|
278
427
|
try {
|
|
279
428
|
await t.onStripeEvent?.(a);
|
|
280
429
|
} catch (e) {
|
|
281
|
-
return
|
|
430
|
+
return $.delete(a.id), V(n, 503, `Handler failed: ${e.message}`);
|
|
282
431
|
}
|
|
283
432
|
return n.json({ received: !0 });
|
|
284
433
|
}), n.use(e.json()), n.get("/_selftest", async (e, t) => {
|
|
285
|
-
if (!
|
|
434
|
+
if (!z(e.query.token)) return V(t, 403, "This check needs its token.");
|
|
286
435
|
try {
|
|
287
|
-
let e = await
|
|
436
|
+
let e = await B();
|
|
288
437
|
return t.status(e.ok ? 200 : 500).json(e);
|
|
289
438
|
} catch (e) {
|
|
290
|
-
return
|
|
439
|
+
return H(t, e);
|
|
440
|
+
}
|
|
441
|
+
}), n.get("/_selftest/checkout", async (e, t) => {
|
|
442
|
+
if (!z(e.query.token)) return V(t, 403, "This check needs its token.");
|
|
443
|
+
if (A() !== "test") return V(t, 403, "The test checkout only runs against a test key (sk_test_).");
|
|
444
|
+
try {
|
|
445
|
+
let n = e.headers.origin || `${e.protocol}://${e.get("host")}`, r = I(String(n), "/api/_selftest/paid", "/api/_selftest/cancelled"), i = await j().checkout.sessions.create({
|
|
446
|
+
mode: "payment",
|
|
447
|
+
line_items: [{
|
|
448
|
+
quantity: 1,
|
|
449
|
+
price_data: {
|
|
450
|
+
currency: "usd",
|
|
451
|
+
product_data: { name: "Self-test payment" },
|
|
452
|
+
unit_amount: 100
|
|
453
|
+
}
|
|
454
|
+
}],
|
|
455
|
+
success_url: r.success,
|
|
456
|
+
cancel_url: r.cancel,
|
|
457
|
+
metadata: { selftest: "true" }
|
|
458
|
+
});
|
|
459
|
+
return t.redirect(303, i.url);
|
|
460
|
+
} catch (e) {
|
|
461
|
+
return H(t, e);
|
|
462
|
+
}
|
|
463
|
+
}), n.get("/_selftest/paid", async (e, t) => {
|
|
464
|
+
let n = String(e.query.session_id || "");
|
|
465
|
+
if (!n) return V(t, 400, "No session id came back.");
|
|
466
|
+
try {
|
|
467
|
+
let e = await j().checkout.sessions.retrieve(n), r = e.payment_status === "paid";
|
|
468
|
+
return t.status(r ? 200 : 402).json({
|
|
469
|
+
ok: r,
|
|
470
|
+
message: r ? "The whole loop works: checkout created, card charged, payment confirmed by the server." : `Stripe says this payment is ${e.payment_status}.`,
|
|
471
|
+
amount: e.amount_total == null ? null : e.amount_total / 100,
|
|
472
|
+
currency: e.currency,
|
|
473
|
+
email: e.customer_details?.email || null
|
|
474
|
+
});
|
|
475
|
+
} catch (e) {
|
|
476
|
+
return H(t, e);
|
|
477
|
+
}
|
|
478
|
+
}), n.get("/_selftest/cancelled", (e, t) => t.json({
|
|
479
|
+
ok: !0,
|
|
480
|
+
message: "Payment cancelled — nothing was charged."
|
|
481
|
+
})), n.get("/_selftest/email", async (e, t) => {
|
|
482
|
+
if (!z(e.query.token)) return V(t, 403, "This check needs its token.");
|
|
483
|
+
if (!v()) return V(t, 503, "RESEND_API_KEY is not set.");
|
|
484
|
+
let n = x();
|
|
485
|
+
if (!n.length) return V(t, 503, "VITE_ADMIN_EMAILS is empty, so there is no admin address to send to.");
|
|
486
|
+
let r = await D(n, E(S.test, {
|
|
487
|
+
appName: process.env.APP_NAME || "your app",
|
|
488
|
+
now: (/* @__PURE__ */ new Date()).toISOString()
|
|
489
|
+
}));
|
|
490
|
+
return t.status(r.error ? 502 : 200).json({
|
|
491
|
+
ok: !r.error,
|
|
492
|
+
sentTo: n,
|
|
493
|
+
from: y(),
|
|
494
|
+
id: r.id,
|
|
495
|
+
error: r.error,
|
|
496
|
+
message: r.error ? "Resend refused the send." : "Sent. If it does not arrive within a minute, check spam — an unverified domain often lands there."
|
|
497
|
+
});
|
|
498
|
+
}), n.get("/email/status", (e, t) => {
|
|
499
|
+
t.json({
|
|
500
|
+
configured: !!v(),
|
|
501
|
+
from: y(),
|
|
502
|
+
adminCount: x().length,
|
|
503
|
+
templates: Object.entries(S).map(([e, t]) => ({
|
|
504
|
+
id: e,
|
|
505
|
+
audience: t.audience,
|
|
506
|
+
description: t.description
|
|
507
|
+
}))
|
|
508
|
+
});
|
|
509
|
+
}), n.post("/email/notify", async (e, t) => {
|
|
510
|
+
let n = Z(Q(e));
|
|
511
|
+
if (n) return V(t, 429, n);
|
|
512
|
+
let { template: r, data: i } = e.body || {}, a = C(String(r || ""));
|
|
513
|
+
if (a) return V(t, 400, a);
|
|
514
|
+
let o = S[String(r)];
|
|
515
|
+
if (o.audience !== "admins") return V(t, 400, `The "${r}" template is written to a customer, not to you.`);
|
|
516
|
+
let s = x();
|
|
517
|
+
if (!s.length) return V(t, 503, "There is no admin address to send to.");
|
|
518
|
+
let c = await D(s, E(o, {
|
|
519
|
+
appName: process.env.APP_NAME || "your app",
|
|
520
|
+
...i || {}
|
|
521
|
+
}));
|
|
522
|
+
return t.status(c.error ? 502 : 200).json({
|
|
523
|
+
success: !c.error,
|
|
524
|
+
id: c.id,
|
|
525
|
+
error: c.error
|
|
526
|
+
});
|
|
527
|
+
}), n.post("/email/send", async (e, t) => {
|
|
528
|
+
let n = Z(Q(e));
|
|
529
|
+
if (n) return V(t, 429, n);
|
|
530
|
+
let { template: r, collection: i, docId: a, data: o } = e.body || {}, s = C(String(r || ""));
|
|
531
|
+
if (s) return V(t, 400, s);
|
|
532
|
+
if (!i || !a) return V(t, 400, "Name the record this email is about.");
|
|
533
|
+
let c = S[String(r)];
|
|
534
|
+
if (c.audience !== "record") return V(t, 400, `The "${r}" template is written to you, not to a customer. Use /email/notify.`);
|
|
535
|
+
try {
|
|
536
|
+
let n = String(e.headers.authorization || ""), r = n.startsWith("Bearer ") ? n.slice(7) : void 0, s = await g(d(), String(i), String(a), r);
|
|
537
|
+
if (!s) return V(t, 404, "That record does not exist.");
|
|
538
|
+
let l = _(s);
|
|
539
|
+
if (!l) return V(t, 422, "That record has no email address on it.");
|
|
540
|
+
let u = E(c, {
|
|
541
|
+
appName: process.env.APP_NAME || "your app",
|
|
542
|
+
...s,
|
|
543
|
+
...o || {}
|
|
544
|
+
}), f = await D([l], u);
|
|
545
|
+
return t.status(f.error ? 502 : 200).json({
|
|
546
|
+
success: !f.error,
|
|
547
|
+
id: f.id,
|
|
548
|
+
error: f.error
|
|
549
|
+
});
|
|
550
|
+
} catch (e) {
|
|
551
|
+
return V(t, 400, e.message);
|
|
291
552
|
}
|
|
292
553
|
}), n.get("/payments/status", (e, t) => {
|
|
293
|
-
let n =
|
|
554
|
+
let n = A();
|
|
294
555
|
t.json({
|
|
295
|
-
configured: !!
|
|
556
|
+
configured: !!k(),
|
|
296
557
|
mode: n,
|
|
297
558
|
webhookConfigured: !!(process.env.STRIPE_WEBHOOK_SECRET || "").trim(),
|
|
298
559
|
message: n ? `Stripe is connected in ${n} mode.` : "Add STRIPE_SECRET_KEY to start taking payments."
|
|
299
560
|
});
|
|
300
561
|
}), n.post("/payments/checkout", async (e, t) => {
|
|
301
|
-
let n = e.body || {}, r =
|
|
302
|
-
if (r) return
|
|
562
|
+
let n = e.body || {}, r = P(n);
|
|
563
|
+
if (r) return V(t, 400, r);
|
|
303
564
|
let i = n.origin || e.headers.origin;
|
|
304
|
-
if (!i) return
|
|
565
|
+
if (!i) return V(t, 400, "The page did not say where to send the payer back to.");
|
|
305
566
|
try {
|
|
306
|
-
let r =
|
|
567
|
+
let r = j(), a = null, o = String(e.headers.authorization || "");
|
|
307
568
|
if (o.startsWith("Bearer ")) try {
|
|
308
569
|
a = await u(o.slice(7), d());
|
|
309
570
|
} catch {
|
|
310
571
|
a = null;
|
|
311
572
|
}
|
|
312
|
-
let s = a ? await
|
|
573
|
+
let s = a ? await G(r, a) : null, c = I(String(i), n.successPath, n.cancelPath), l = n.priceId ? {
|
|
313
574
|
price: n.priceId,
|
|
314
575
|
quantity: n.quantity || 1
|
|
315
576
|
} : {
|
|
@@ -317,7 +578,7 @@ function F(t = {}) {
|
|
|
317
578
|
price_data: {
|
|
318
579
|
currency: n.currency || "usd",
|
|
319
580
|
product_data: { name: String(n.productName) },
|
|
320
|
-
unit_amount:
|
|
581
|
+
unit_amount: F(Number(n.amount))
|
|
321
582
|
}
|
|
322
583
|
}, f = await r.checkout.sessions.create({
|
|
323
584
|
mode: n.mode || "payment",
|
|
@@ -337,11 +598,11 @@ function F(t = {}) {
|
|
|
337
598
|
url: f.url
|
|
338
599
|
});
|
|
339
600
|
} catch (e) {
|
|
340
|
-
return
|
|
601
|
+
return H(t, e);
|
|
341
602
|
}
|
|
342
603
|
}), n.get("/payments/session/:id", async (e, t) => {
|
|
343
604
|
try {
|
|
344
|
-
let n = await
|
|
605
|
+
let n = await j().checkout.sessions.retrieve(String(e.params.id));
|
|
345
606
|
return t.json({
|
|
346
607
|
success: !0,
|
|
347
608
|
paid: n.payment_status === "paid",
|
|
@@ -354,11 +615,11 @@ function F(t = {}) {
|
|
|
354
615
|
metadata: n.metadata || {}
|
|
355
616
|
});
|
|
356
617
|
} catch (e) {
|
|
357
|
-
return (e instanceof Error ? e.message : "Unknown error").includes("STRIPE_SECRET_KEY") ?
|
|
618
|
+
return (e instanceof Error ? e.message : "Unknown error").includes("STRIPE_SECRET_KEY") ? H(t, e) : V(t, 404, "That payment could not be found.");
|
|
358
619
|
}
|
|
359
|
-
}), n.get("/payments/me",
|
|
620
|
+
}), n.get("/payments/me", U, async (e, t) => {
|
|
360
621
|
try {
|
|
361
|
-
let n =
|
|
622
|
+
let n = j(), r = await W(n, e.user);
|
|
362
623
|
if (!r) return t.json({
|
|
363
624
|
success: !0,
|
|
364
625
|
customerId: null,
|
|
@@ -374,7 +635,7 @@ function F(t = {}) {
|
|
|
374
635
|
}), n.charges.list({
|
|
375
636
|
customer: r.id,
|
|
376
637
|
limit: 20
|
|
377
|
-
})]), o = i.data.map(
|
|
638
|
+
})]), o = i.data.map(q), s = a.data.map(J);
|
|
378
639
|
return t.json({
|
|
379
640
|
success: !0,
|
|
380
641
|
customerId: r.id,
|
|
@@ -384,14 +645,14 @@ function F(t = {}) {
|
|
|
384
645
|
payments: s
|
|
385
646
|
});
|
|
386
647
|
} catch (e) {
|
|
387
|
-
return
|
|
648
|
+
return H(t, e);
|
|
388
649
|
}
|
|
389
|
-
}), n.post("/payments/portal",
|
|
650
|
+
}), n.post("/payments/portal", U, async (e, t) => {
|
|
390
651
|
try {
|
|
391
|
-
let n =
|
|
392
|
-
if (!r) return
|
|
652
|
+
let n = j(), r = await W(n, e.user);
|
|
653
|
+
if (!r) return V(t, 404, "There is nothing to manage on this account yet.");
|
|
393
654
|
let i = e.body?.origin || e.headers.origin;
|
|
394
|
-
if (!i) return
|
|
655
|
+
if (!i) return V(t, 400, "The page did not say where to send them back to.");
|
|
395
656
|
let a = await n.billingPortal.sessions.create({
|
|
396
657
|
customer: r.id,
|
|
397
658
|
return_url: String(i).replace(/\/+$/, "")
|
|
@@ -401,9 +662,9 @@ function F(t = {}) {
|
|
|
401
662
|
url: a.url
|
|
402
663
|
});
|
|
403
664
|
} catch (e) {
|
|
404
|
-
return
|
|
665
|
+
return H(t, e);
|
|
405
666
|
}
|
|
406
667
|
}), n;
|
|
407
668
|
}
|
|
408
669
|
//#endregion
|
|
409
|
-
export {
|
|
670
|
+
export { M as MAX_ADHOC_AMOUNT, N as MIN_ADHOC_AMOUNT, S as TEMPLATES, x as adminEmails, oe as apiRoutes, P as checkoutProblem, _ as emailOnRecord, w as escapeHtml, T as fill, m as fromFields, L as grantsAccess, h as pathProblem, d as projectId, g as readDocument, E as renderTemplate, I as returnUrls, B as runSelfTest, z as selfTestAllowed, A as stripeMode, u as verifyIdToken };
|
package/package.json
CHANGED
package/server.template.ts
CHANGED
|
@@ -9,13 +9,19 @@
|
|
|
9
9
|
*
|
|
10
10
|
* WHAT TO PUT IN SECRETS
|
|
11
11
|
* STRIPE_SECRET_KEY starts sk_test_ while you are trying it out
|
|
12
|
+
* RESEND_API_KEY from resend.com — turns on email
|
|
13
|
+
* EMAIL_FROM optional; defaults to Resend's shared sender
|
|
14
|
+
* EMAIL_REPLY_TO optional; where replies should land
|
|
15
|
+
* APP_NAME optional; the name used inside emails
|
|
12
16
|
* STRIPE_WEBHOOK_SECRET optional, and only once the app is published
|
|
13
|
-
* SELFTEST_TOKEN optional; once set,
|
|
17
|
+
* SELFTEST_TOKEN optional; once set, every check below asks for it
|
|
14
18
|
* VITE_FIREBASE_PROJECT_ID already there — the server reads the same one
|
|
19
|
+
* VITE_ADMIN_EMAILS already there — who owner notifications go to
|
|
15
20
|
*
|
|
16
|
-
* IS IT WORKING?
|
|
17
|
-
*
|
|
18
|
-
*
|
|
21
|
+
* IS IT WORKING? All three open in the browser — no terminal needed.
|
|
22
|
+
* /api/_selftest what is set, what connects, what is missing
|
|
23
|
+
* /api/_selftest/checkout charges $1 on a test card and confirms it
|
|
24
|
+
* /api/_selftest/email sends a test email to the admin addresses
|
|
19
25
|
*/
|
|
20
26
|
import express, { Request, Response } from 'express';
|
|
21
27
|
import path from 'path';
|