@rentaltide/cli 0.3.0 → 0.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.
@@ -93,10 +93,16 @@ async function create() {
93
93
  const supportEmail = existing?.supportEmail || (await ask('Support email:'));
94
94
  const countries = existing?.countries?.length
95
95
  ? existing.countries
96
- : (await ask('Licensed countries (ISO, comma separated):')).split(',').map((c) => c.trim().toUpperCase()).filter(Boolean);
96
+ : (await ask('Licensed countries (ISO, comma separated):'))
97
+ .split(',')
98
+ .map((c) => c.trim().toUpperCase())
99
+ .filter(Boolean);
97
100
  const currencies = existing?.currencies?.length
98
101
  ? existing.currencies
99
- : (await ask('Currencies (ISO, comma separated):')).split(',').map((c) => c.trim().toUpperCase()).filter(Boolean);
102
+ : (await ask('Currencies (ISO, comma separated):'))
103
+ .split(',')
104
+ .map((c) => c.trim().toUpperCase())
105
+ .filter(Boolean);
100
106
  if (!name || !baseUrl)
101
107
  fail('A name and a base URL are required.');
102
108
  if (!baseUrl.startsWith('https://')) {
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import { webhook } from './commands/webhook.js';
16
16
  import { open } from './commands/open.js';
17
17
  import { coverage } from './commands/coverage.js';
18
18
  import { bold, dim, say } from './ui.js';
19
- const VERSION = '0.3.0';
19
+ const VERSION = '0.4.0';
20
20
  function usage() {
21
21
  say(`${bold('rentaltide')} ${dim(VERSION)} — build apps for RentalTide`);
22
22
  say();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rentaltide/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Build RentalTide apps and coverage offerings: scaffold, run against your sandbox, push config, and test an underwriter the way RentalTide calls it.",
5
5
  "license": "MIT",
6
6
  "author": "RentalTide Inc.",
@@ -1,8 +1,12 @@
1
1
  # **APP_NAME**
2
2
 
3
- A RentalTide coverage underwriter. Six endpoints, no dependencies.
3
+ A RentalTide coverage underwriter, built on
4
+ [`@rentaltide/underwriter`](https://www.npmjs.com/package/@rentaltide/underwriter).
5
+ The SDK verifies signatures, routes calls, checks what you return and makes
6
+ `/bind` idempotent — `server.mjs` is underwriting and nothing else.
4
7
 
5
8
  ```bash
9
+ npm install
6
10
  export SIGNING_SECRET=whsec_... # shown once when you created the offering
7
11
  npm run dev # http://localhost:8787
8
12
 
@@ -25,8 +29,9 @@ rentaltide coverage status # what is still missing before review
25
29
  rentaltide coverage submit
26
30
  ```
27
31
 
28
- - `server.mjs` — your underwriting. Replace the pricing and the questions; keep
29
- the signature check.
32
+ - `server.mjs` — your underwriting. Replace the pricing and the questions.
33
+ - Before selling a real policy, give `createUnderwriter` a `store` so bound
34
+ policies survive a restart; see the SDK README.
30
35
  - `rentaltide.coverage.json` — base URL, licensed countries, currencies, quote
31
36
  timeout. `push` applies it, `pull` brings it back.
32
37
 
@@ -5,5 +5,8 @@
5
5
  "scripts": {
6
6
  "start": "node server.mjs",
7
7
  "dev": "node --watch server.mjs"
8
+ },
9
+ "dependencies": {
10
+ "@rentaltide/underwriter": "^0.1.0"
8
11
  }
9
12
  }
@@ -1,221 +1,109 @@
1
1
  /**
2
2
  * __APP_NAME__ — a RentalTide coverage underwriter.
3
3
  *
4
- * Six endpoints. RentalTide calls you; you never call a renter, never render a
5
- * page, and never see a card number.
4
+ * RentalTide calls you. You never call a renter, never render a page, and never
5
+ * see a card number. The SDK verifies the signature, routes the call, checks
6
+ * what you return, and makes /bind idempotent — so everything below is
7
+ * underwriting and nothing else.
6
8
  *
7
- * POST /quote a boat and a time, no renter identity → a price
8
- * POST /schema your underwriting questions → a field list
9
- * POST /requote the answers → the final price
10
- * POST /bind AFTER payment succeeds → a policy
11
- * POST /reschedule the booking's dates moved → does it stand
12
- * POST /cancel cancelled or refunded → void it
13
- *
14
- * Plain Node, no dependencies, so nothing here is hidden behind a framework.
15
- * Replace the pricing and the questions; keep the signature check exactly as it
16
- * is — it is the only thing standing between your policies and anyone on the
17
- * internet.
18
- *
19
- * Run it: npm start
9
+ * Run it: npm run dev
20
10
  * Test it: rentaltide coverage test --url http://localhost:8787
21
11
  */
22
12
 
23
- import http from 'node:http';
24
13
  import crypto from 'node:crypto';
14
+ import { createUnderwriter, decline, NO_QUOTE } from '@rentaltide/underwriter';
25
15
 
26
- const PORT = Number(process.env.PORT || 8787);
27
- const SECRET = process.env.SIGNING_SECRET || '';
28
- const SCHEMA_VERSION = '2026-09-01';
29
-
30
- if (!SECRET) {
31
- console.error('Set SIGNING_SECRET — the secret RentalTide showed you once, at creation.');
32
- process.exit(1);
33
- }
34
-
35
- /** Policies in memory. Use your real book of business here. */
36
- const policies = new Map();
37
-
38
- const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
39
-
40
- /**
41
- * Verify the signature the way RentalTide sends it.
42
- *
43
- * Timing-safe, and a stale timestamp is refused: a replayed bind is a second
44
- * policy on somebody else's booking.
45
- */
46
- function verify(headers, rawBody) {
47
- const signature = headers['x-rentaltide-signature'] || '';
48
- const timestamp = headers['x-rentaltide-timestamp'] || '';
49
- if (!signature || !timestamp) return false;
16
+ const rentaltide = createUnderwriter({
17
+ // Shown once when you created the offering. `rentaltide coverage create`
18
+ // saved a copy to ~/.rentaltide; put it here too.
19
+ secret: process.env.SIGNING_SECRET,
50
20
 
51
- const age = Date.now() - new Date(timestamp).getTime();
52
- if (!Number.isFinite(age) || Math.abs(age) > 5 * 60_000) return false;
53
-
54
- const expected =
55
- 'v1=' + crypto.createHmac('sha256', SECRET).update(`${timestamp}.${rawBody}`).digest('hex');
56
- const a = Buffer.from(signature);
57
- const b = Buffer.from(expected);
58
- return a.length === b.length && crypto.timingSafeEqual(a, b);
59
- }
60
-
61
- // ── Your underwriting ───────────────────────────────────────────────────────
21
+ // Memory by default, which forgets on restart. Point this at your own
22
+ // database before you sell a real policy: a retry after a restart would
23
+ // otherwise issue a SECOND policy on the same booking.
24
+ // store: { get: (quoteId) => …, set: (quoteId, policy) => … },
25
+ });
62
26
 
63
27
  const BASE_RATE = 0.065;
64
28
  const FLOOR = 12;
65
29
  const MINIMUM_BOOKING = 50;
66
30
 
67
- function priceFor({ bookingSubtotal, days }) {
68
- return round2(Math.max(bookingSubtotal * BASE_RATE, FLOOR) + Math.max(0, days - 1) * 3);
69
- }
70
-
71
- const QUESTIONS = {
72
- version: SCHEMA_VERSION,
73
- fields: [
74
- {
75
- id: 'date_of_birth',
76
- label: 'Date of birth',
77
- type: 'date',
78
- required: true,
79
- notBefore: '1920-01-01',
80
- minAgeYears: 18,
81
- },
82
- { id: 'boating_licence', label: 'Boating licence number', type: 'text', required: false },
83
- {
84
- id: 'prior_claims_3y',
85
- label: 'Any claims in the last 3 years?',
86
- type: 'checkbox',
87
- required: false,
88
- },
89
- ],
90
- };
91
-
92
- // ── Handlers ────────────────────────────────────────────────────────────────
93
-
94
- const routes = {
95
- '/quote': (body) => {
96
- const subtotal = Number(body.bookingSubtotal) || 0;
97
- const days = Number(body.days) || 1;
98
-
99
- // Decline a risk you do not want rather than pricing it at zero. No card
100
- // renders and the booking proceeds without you.
101
- if (subtotal < MINIMUM_BOOKING) return { error: 'below_minimum' };
31
+ rentaltide
32
+ /**
33
+ * A boat and a time, no renter identity. Answer inside your quote timeout
34
+ * (default 800ms) or no card renders and the booking proceeds without you.
35
+ */
36
+ .quote(({ bookingSubtotal, days, currency }) => {
37
+ // Decline a risk you do not want rather than pricing it at zero.
38
+ if (bookingSubtotal < MINIMUM_BOOKING) return NO_QUOTE;
102
39
 
103
40
  return {
104
- // Anything you can price the same cart from twice.
105
- quoteId: `q_${crypto.randomBytes(12).toString('hex')}`,
106
- expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(),
107
- // A JSON number. "44.50" is rejected.
108
- premium: priceFor({ bookingSubtotal: subtotal, days }),
109
- currency: body.currency || 'USD',
110
- coverageLimit: 25000,
111
- // A reducing effect MUST carry a value, or it renders to the renter as a
112
- // saving and then changes nothing at hold time.
41
+ quoteId: `q_${crypto.randomBytes(8).toString('hex')}`,
42
+ premium: Math.max(bookingSubtotal * BASE_RATE, FLOOR) + Math.max(0, days - 1) * 3,
43
+ currency,
44
+ coverageLimit: 25_000,
113
45
  depositEffect: 'reduce_to',
114
46
  depositEffectValue: 500,
115
47
  displayName: '__APP_NAME__',
116
48
  shortDescription: 'Covers hull damage up to $25,000. Drops your deposit to $500.',
117
49
  terms: 'Excess $500 per incident. Operator must be 18 or over.',
118
- questionSchemaVersion: SCHEMA_VERSION,
50
+ questionSchemaVersion: '2026-09-01',
119
51
  };
120
- },
121
-
122
- '/schema': () => QUESTIONS,
123
-
124
- '/requote': (body) => {
125
- const answers = body.answers || {};
126
- const quoteId = String(body.quoteId || '');
127
-
52
+ })
53
+
54
+ /** Your underwriting questions. RentalTide draws them in its own checkout. */
55
+ .schema(() => ({
56
+ version: '2026-09-01',
57
+ fields: [
58
+ {
59
+ id: 'date_of_birth',
60
+ label: 'Date of birth',
61
+ type: 'date',
62
+ required: true,
63
+ minAgeYears: 18,
64
+ },
65
+ { id: 'boating_licence', label: 'Boating licence number', type: 'text' },
66
+ { id: 'prior_claims_3y', label: 'Any claims in the last 3 years?', type: 'checkbox' },
67
+ ],
68
+ }))
69
+
70
+ /** The answers, and your final price. A decline is a normal outcome. */
71
+ .requote(({ basePremium = FLOOR, answers }) => {
128
72
  const dob = answers.date_of_birth ? new Date(String(answers.date_of_birth)) : null;
129
- if (dob && !Number.isNaN(dob.getTime())) {
130
- const years = (Date.now() - dob.getTime()) / (365.25 * 86_400_000);
131
- // A decline is a normal outcome, and a 200. An error here drops the
132
- // coverage without telling the renter why.
133
- if (years < 18) {
134
- return { quoteId, decision: 'declined', declineReason: 'Operator must be 18 or over.' };
135
- }
73
+ if (dob && (Date.now() - dob.getTime()) / (365.25 * 86_400_000) < 18) {
74
+ return decline('Operator must be 18 or over.');
136
75
  }
137
76
 
138
- const base = Number(body.basePremium) || FLOOR;
139
- const premium = answers.prior_claims_3y === true ? round2(base * 1.4) : base;
140
-
77
+ // A higher price than the estimate means RentalTide asks the renter to
78
+ // accept it before charging. Lower proceeds silently.
141
79
  return {
142
- quoteId,
143
80
  decision: 'accepted',
144
- premium,
145
- finalUntil: new Date(Date.now() + 30 * 60_000).toISOString(),
81
+ premium: answers.prior_claims_3y === true ? basePremium * 1.4 : basePremium,
146
82
  };
147
- },
148
-
149
- '/bind': (body) => {
150
- const quoteId = String(body.quoteId || '');
151
-
152
- // Idempotent on quoteId. RentalTide retries on a timeout or a 5xx, and a
153
- // retry that writes a second policy bills a renter twice for one booking.
154
- const existing = policies.get(quoteId);
155
- if (existing) return existing;
83
+ })
156
84
 
85
+ /** Only after payment succeeded. The only call carrying renter identity. */
86
+ .bind(({ quoteId, bookingReference, renter }) => {
87
+ console.log(`binding ${quoteId} for ${bookingReference} — ${renter.email}`);
157
88
  const policyId = `POL-${crypto.randomBytes(4).toString('hex').toUpperCase()}`;
158
- const policy = {
89
+ return {
159
90
  policyId,
160
91
  documentUrl: `https://example.com/policies/${policyId}.pdf`, // https, always
161
92
  effectiveAt: new Date().toISOString(),
162
- expiresAt: null,
163
93
  };
164
- policies.set(quoteId, policy);
165
- console.log(`bound ${policyId} for booking ${body.bookingReference}`);
166
- return policy;
167
- },
168
-
169
- '/reschedule': (body) => {
170
- // The booking moved after you bound it. Say whether the policy still
171
- // stands; you cannot reprice here, because the renter has already paid.
172
- console.log(`policy ${body.policyId} moved to ${body.startsAt}`);
94
+ })
95
+
96
+ /**
97
+ * The booking's dates moved after you bound it. Not a requote — the renter
98
+ * has already paid — so the only question is whether the policy still stands.
99
+ */
100
+ .reschedule(({ policyId, startsAt, endsAt }) => {
101
+ console.log(`policy ${policyId} now runs ${startsAt} ${endsAt}`);
173
102
  return { decision: 'accepted' };
174
- },
175
-
176
- '/cancel': (body) => {
177
- for (const [quoteId, policy] of policies.entries()) {
178
- if (policy.policyId === body.policyId) policies.delete(quoteId);
179
- }
180
- return { cancelled: true };
181
- },
182
- };
183
-
184
- // ── Wiring ──────────────────────────────────────────────────────────────────
185
-
186
- const server = http.createServer((req, res) => {
187
- const path = (req.url || '/').split('?')[0].replace(/\/+$/, '') || '/';
188
- const send = (status, body) => {
189
- res.writeHead(status, { 'content-type': 'application/json' });
190
- res.end(JSON.stringify(body));
191
- };
192
-
193
- if (req.method === 'GET') {
194
- return send(200, path === '/schema' ? QUESTIONS : { ok: true, service: '__APP_SLUG__' });
195
- }
196
- if (req.method !== 'POST' || !routes[path]) return send(404, { error: 'not_found' });
103
+ })
197
104
 
198
- let raw = '';
199
- req.on('data', (chunk) => {
200
- raw += chunk;
201
- // Nobody sends a megabyte of quote.
202
- if (raw.length > 1_000_000) req.destroy();
105
+ .cancel(({ policyId, reason }) => {
106
+ console.log(`cancelling ${policyId}: ${reason}`);
203
107
  });
204
- req.on('end', () => {
205
- if (!verify(req.headers, raw)) return send(401, { error: 'bad_signature' });
206
- let body;
207
- try {
208
- body = raw ? JSON.parse(raw) : {};
209
- } catch {
210
- return send(400, { error: 'bad_json' });
211
- }
212
- try {
213
- send(200, routes[path](body));
214
- } catch (err) {
215
- console.error(err);
216
- send(500, { error: 'internal' });
217
- }
218
- });
219
- });
220
108
 
221
- server.listen(PORT, () => console.log(`__APP_NAME__ listening on http://localhost:${PORT}`));
109
+ rentaltide.listen(Number(process.env.PORT) || 8787);