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.
@@ -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';
@@ -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,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`).replace(/"/g,`&quot;`).replace(/'/g,`&#39;`)}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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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 f = null;
58
- function p() {
198
+ var O = null;
199
+ function k() {
59
200
  return (process.env.STRIPE_SECRET_KEY || "").trim();
60
201
  }
61
- function m() {
62
- let e = p();
202
+ function A() {
203
+ let e = k();
63
204
  return e ? e.startsWith("sk_live_") ? "live" : "test" : null;
64
205
  }
65
- function h() {
66
- let e = p();
206
+ function j() {
207
+ let e = k();
67
208
  if (!e) throw Error("STRIPE_SECRET_KEY is not set.");
68
- return f ||= new n(e), f;
209
+ return O ||= new n(e), O;
69
210
  }
70
- var g = Number(process.env.STRIPE_MAX_ADHOC_AMOUNT || 1e4), _ = .5;
71
- function v(e) {
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 > g) return `That amount is above the ${g} limit for typed prices. Use a price created in Stripe.`;
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 y(e) {
227
+ function F(e) {
87
228
  return Math.round(e * 100);
88
229
  }
89
- function b(e, t, n) {
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 x(e) {
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 S = (e) => !!(process.env[e] || "").trim();
105
- function C(e) {
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 w() {
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 (!p()) e.push({
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 = m();
288
+ let t = A();
148
289
  try {
149
- let n = await h().accounts.retrieve();
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(S("STRIPE_WEBHOOK_SECRET") ? {
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(S("RESEND_API_KEY") ? {
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 T = (e, t, n, r = {}) => e.status(t).json({
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 E(e, t) {
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 T(e, r ? 503 : 500, n, r ? { requiresSetup: !0 } : {});
340
+ return V(e, r ? 503 : 500, n, r ? { requiresSetup: !0 } : {});
200
341
  }
201
- async function D(e, t, n) {
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 T(t, 401, "Please sign in first.");
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 T(t, 401, e.message);
348
+ return V(t, 401, e.message);
208
349
  }
209
350
  }
210
- async function O(e, t) {
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 k(e, t) {
217
- let n = await O(e, t);
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 A(e) {
367
+ function K(e) {
227
368
  return e?.current_period_end ?? e?.items?.data?.[0]?.current_period_end ?? null;
228
369
  }
229
- function j(e) {
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: x(e.status),
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: A(e),
382
+ currentPeriodEnd: K(e),
242
383
  cancelAtPeriodEnd: !!e.cancel_at_period_end
243
384
  };
244
385
  }
245
- function M(e) {
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 N = /* @__PURE__ */ new Set();
259
- function P(e) {
260
- return N.has(e) ? !0 : (N.add(e), N.size > 500 && N.delete(N.values().next().value), !1);
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 F(t = {}) {
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 T(n, 503, "STRIPE_WEBHOOK_SECRET is not set.");
267
- if (!i) return T(n, 400, "Missing stripe-signature header.");
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 = h().webhooks.constructEvent(e.body, i, r);
419
+ a = j().webhooks.constructEvent(e.body, i, r);
271
420
  } catch (e) {
272
- return T(n, 400, `Webhook signature check failed: ${e.message}`);
421
+ return V(n, 400, `Webhook signature check failed: ${e.message}`);
273
422
  }
274
- if (P(a.id)) return n.json({
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 N.delete(a.id), T(n, 503, `Handler failed: ${e.message}`);
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 (!C(e.query.token)) return T(t, 403, "This check needs its token.");
434
+ if (!z(e.query.token)) return V(t, 403, "This check needs its token.");
286
435
  try {
287
- let e = await w();
436
+ let e = await B();
288
437
  return t.status(e.ok ? 200 : 500).json(e);
289
438
  } catch (e) {
290
- return E(t, e);
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 = m();
554
+ let n = A();
294
555
  t.json({
295
- configured: !!p(),
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 = v(n);
302
- if (r) return T(t, 400, r);
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 T(t, 400, "The page did not say where to send the payer back to.");
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 = h(), a = null, o = String(e.headers.authorization || "");
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 k(r, a) : null, c = b(String(i), n.successPath, n.cancelPath), l = n.priceId ? {
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: y(Number(n.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 E(t, e);
601
+ return H(t, e);
341
602
  }
342
603
  }), n.get("/payments/session/:id", async (e, t) => {
343
604
  try {
344
- let n = await h().checkout.sessions.retrieve(String(e.params.id));
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") ? E(t, e) : T(t, 404, "That payment could not be found.");
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", D, async (e, t) => {
620
+ }), n.get("/payments/me", U, async (e, t) => {
360
621
  try {
361
- let n = h(), r = await O(n, e.user);
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(j), s = a.data.map(M);
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 E(t, e);
648
+ return H(t, e);
388
649
  }
389
- }), n.post("/payments/portal", D, async (e, t) => {
650
+ }), n.post("/payments/portal", U, async (e, t) => {
390
651
  try {
391
- let n = h(), r = await O(n, e.user);
392
- if (!r) return T(t, 404, "There is nothing to manage on this account yet.");
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 T(t, 400, "The page did not say where to send them back to.");
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 E(t, e);
665
+ return H(t, e);
405
666
  }
406
667
  }), n;
407
668
  }
408
669
  //#endregion
409
- export { g as MAX_ADHOC_AMOUNT, _ as MIN_ADHOC_AMOUNT, F as apiRoutes, v as checkoutProblem, x as grantsAccess, d as projectId, b as returnUrls, w as runSelfTest, C as selfTestAllowed, m as stripeMode, u as verifyIdToken };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "your-ai-workflow-firebase-os",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Your AI Firebase — a complete Firebase-powered admin app in one React component.",
5
5
  "type": "module",
6
6
  "main": "dist/your-ai-workflow-firebase-os.cjs.js",
@@ -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, /api/_selftest asks for it
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
- * Open /api/_selftest in the browser. It answers with a list rather than a
18
- * blank page: what is set, what connects, and what is still missing.
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';