@rentaltide/cli 0.2.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.
- package/README.md +16 -4
- package/dist/commands/coverage.js +193 -1
- package/dist/commands/init.js +1 -1
- package/dist/config.js +1 -3
- package/dist/coverageProbe.js +2 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/templates/underwriter/README.md +9 -4
- package/templates/underwriter/package.json +3 -0
- package/templates/underwriter/server.mjs +70 -182
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ installed points all of them at your laptop.
|
|
|
39
39
|
## Embedded insurance
|
|
40
40
|
|
|
41
41
|
An underwriter builds no iframe and holds no OAuth token — RentalTide calls
|
|
42
|
-
|
|
42
|
+
_them_. Different world, same CLI:
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
45
|
rentaltide init --underwriter acme-cover
|
|
@@ -47,13 +47,25 @@ cd acme-cover
|
|
|
47
47
|
export SIGNING_SECRET=whsec_… # shown once when you created the offering
|
|
48
48
|
npm run dev # six endpoints on :8787
|
|
49
49
|
|
|
50
|
-
rentaltide coverage
|
|
51
|
-
rentaltide coverage secret
|
|
50
|
+
rentaltide coverage create # register it, secret saved automatically
|
|
52
51
|
rentaltide coverage test --url http://localhost:8787
|
|
53
52
|
```
|
|
54
53
|
|
|
54
|
+
Or fire one call at a time while you are writing the thing — signed exactly the
|
|
55
|
+
way we sign it:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
rentaltide coverage quote --subtotal 1200 --currency CAD
|
|
59
|
+
rentaltide coverage requote --quote-id q_… --answers '{"prior_claims_3y":true}'
|
|
60
|
+
rentaltide coverage bind --quote-id q_…
|
|
61
|
+
rentaltide coverage cancel --policy-id POL-…
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Each prints the signed request, the status, the timing and the body, and exits
|
|
65
|
+
non-zero on anything but a 200.
|
|
66
|
+
|
|
55
67
|
`coverage test` signs its requests exactly as the platform does and checks the
|
|
56
|
-
rules that fail
|
|
68
|
+
rules that fail _quietly_:
|
|
57
69
|
|
|
58
70
|
```
|
|
59
71
|
✓ Refuses an unsigned request
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { request } from '../api.js';
|
|
10
10
|
import { readSigningSecret, requireCoverageConfig, readCoverageConfig, saveSigningSecret, writeCoverageConfig, COVERAGE_CONFIG_FILENAME, } from '../config.js';
|
|
11
|
-
import { probe } from '../coverageProbe.js';
|
|
11
|
+
import { call, probe } from '../coverageProbe.js';
|
|
12
12
|
import { ask, bold, confirm, cyan, dim, fail, green, ok, red, say, step, warn } from '../ui.js';
|
|
13
13
|
const toConfig = (provider, existing) => ({
|
|
14
14
|
providerId: provider.id,
|
|
@@ -34,6 +34,8 @@ async function getProvider(providerId) {
|
|
|
34
34
|
export async function coverage(args) {
|
|
35
35
|
const [sub, ...rest] = args;
|
|
36
36
|
switch (sub) {
|
|
37
|
+
case 'create':
|
|
38
|
+
return create();
|
|
37
39
|
case 'link':
|
|
38
40
|
return link();
|
|
39
41
|
case 'pull':
|
|
@@ -51,8 +53,11 @@ export async function coverage(args) {
|
|
|
51
53
|
case 'submit':
|
|
52
54
|
return submit();
|
|
53
55
|
default:
|
|
56
|
+
if (sub && ENDPOINTS.includes(sub))
|
|
57
|
+
return callOne(sub, rest);
|
|
54
58
|
say(`${bold('rentaltide coverage')} <command>`);
|
|
55
59
|
say();
|
|
60
|
+
say(' create register this offering with RentalTide');
|
|
56
61
|
say(' link attach this directory to one of your coverage offerings');
|
|
57
62
|
say(' pull write the offering into rentaltide.coverage.json');
|
|
58
63
|
say(' push apply rentaltide.coverage.json to the offering');
|
|
@@ -61,8 +66,82 @@ export async function coverage(args) {
|
|
|
61
66
|
say(' secret store the signing secret for this machine');
|
|
62
67
|
say(' status what still has to be true before review');
|
|
63
68
|
say(' submit submit the offering for review');
|
|
69
|
+
say();
|
|
70
|
+
say(` ${bold('One call at a time')} — signed the way we sign it:`);
|
|
71
|
+
say(' quote | schema | requote | bind | reschedule | cancel');
|
|
72
|
+
say(dim(' e.g. rentaltide coverage quote --subtotal 1200 --currency CAD'));
|
|
73
|
+
say(dim(' rentaltide coverage requote --quote-id q_… --answers \'{"prior_claims_3y":true}\''));
|
|
74
|
+
say(dim(' rentaltide coverage bind --data \'{"quoteId":"q_…"}\''));
|
|
64
75
|
}
|
|
65
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Register the offering.
|
|
79
|
+
*
|
|
80
|
+
* The one call that used to need a hand-built curl and a bearer token dug out
|
|
81
|
+
* of the portal — for a partner whose first act is to read a contract, that is
|
|
82
|
+
* a strange first step. It also puts the signing secret straight where the rest
|
|
83
|
+
* of the CLI looks for it: RentalTide shows it exactly once and cannot return
|
|
84
|
+
* it again, so a partner who pastes it into a terminal and closes the window
|
|
85
|
+
* has lost it.
|
|
86
|
+
*/
|
|
87
|
+
async function create() {
|
|
88
|
+
const existing = readCoverageConfig();
|
|
89
|
+
const name = existing?.name || (await ask('Offering name:'));
|
|
90
|
+
const baseUrl = existing?.baseUrl && existing.baseUrl !== 'https://example.com'
|
|
91
|
+
? existing.baseUrl
|
|
92
|
+
: await ask('Base URL (https):');
|
|
93
|
+
const supportEmail = existing?.supportEmail || (await ask('Support email:'));
|
|
94
|
+
const countries = existing?.countries?.length
|
|
95
|
+
? existing.countries
|
|
96
|
+
: (await ask('Licensed countries (ISO, comma separated):'))
|
|
97
|
+
.split(',')
|
|
98
|
+
.map((c) => c.trim().toUpperCase())
|
|
99
|
+
.filter(Boolean);
|
|
100
|
+
const currencies = existing?.currencies?.length
|
|
101
|
+
? existing.currencies
|
|
102
|
+
: (await ask('Currencies (ISO, comma separated):'))
|
|
103
|
+
.split(',')
|
|
104
|
+
.map((c) => c.trim().toUpperCase())
|
|
105
|
+
.filter(Boolean);
|
|
106
|
+
if (!name || !baseUrl)
|
|
107
|
+
fail('A name and a base URL are required.');
|
|
108
|
+
if (!baseUrl.startsWith('https://')) {
|
|
109
|
+
fail('The base URL must be https.', 'We sign every call to it and send renter identity at bind.');
|
|
110
|
+
}
|
|
111
|
+
const result = await request('/developer/coverage-providers', {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
body: {
|
|
114
|
+
name,
|
|
115
|
+
baseUrl,
|
|
116
|
+
supportEmail: supportEmail || undefined,
|
|
117
|
+
supportedCountries: countries,
|
|
118
|
+
supportedCurrencies: currencies,
|
|
119
|
+
quoteTimeoutMs: existing?.quoteTimeoutMs ?? 800,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
writeCoverageConfig({
|
|
123
|
+
providerId: result.provider.id,
|
|
124
|
+
name: result.provider.name,
|
|
125
|
+
baseUrl,
|
|
126
|
+
supportEmail: supportEmail || undefined,
|
|
127
|
+
countries,
|
|
128
|
+
currencies,
|
|
129
|
+
quoteTimeoutMs: existing?.quoteTimeoutMs ?? 800,
|
|
130
|
+
dev: existing?.dev ?? { baseUrl: 'http://localhost:8787' },
|
|
131
|
+
});
|
|
132
|
+
// Stored before it is printed: if the terminal scrolls away or the window
|
|
133
|
+
// closes, the secret is still on this machine.
|
|
134
|
+
saveSigningSecret(result.provider.id, result.signingSecret);
|
|
135
|
+
ok(`Created ${result.provider.name} (${result.provider.slug}).`);
|
|
136
|
+
say();
|
|
137
|
+
say(` ${bold('Signing secret')} ${result.signingSecret}`);
|
|
138
|
+
say(` ${dim('Shown once. Saved to ~/.rentaltide/credentials.json for `coverage test`.')}`);
|
|
139
|
+
say(` ${dim('Put it in your own environment as SIGNING_SECRET too — we cannot show it again.')}`);
|
|
140
|
+
say();
|
|
141
|
+
say(`It works on sandbox locations now, with no approval step.`);
|
|
142
|
+
say(` ${cyan('rentaltide coverage test')} ${dim('prove your endpoints')}`);
|
|
143
|
+
say(` ${cyan('rentaltide coverage status')} ${dim('what is still missing before review')}`);
|
|
144
|
+
}
|
|
66
145
|
async function link() {
|
|
67
146
|
const providers = await listProviders();
|
|
68
147
|
if (providers.length === 0) {
|
|
@@ -271,3 +350,116 @@ async function submit() {
|
|
|
271
350
|
await request(`/developer/coverage-providers/${config.providerId}/submit`, { method: 'POST' });
|
|
272
351
|
ok('Submitted for review. It keeps working in sandbox meanwhile.');
|
|
273
352
|
}
|
|
353
|
+
// ============================================================================
|
|
354
|
+
// One call at a time
|
|
355
|
+
//
|
|
356
|
+
// `coverage test` runs the whole contract and judges it. Sometimes you just
|
|
357
|
+
// want to fire one endpoint with a payload you control and read what comes
|
|
358
|
+
// back — while you are writing the thing, that is most of the time.
|
|
359
|
+
// ============================================================================
|
|
360
|
+
/** A realistic default body per endpoint, so a bare command does something. */
|
|
361
|
+
function defaultBody(endpoint, args) {
|
|
362
|
+
const flag = (name) => {
|
|
363
|
+
const index = args.indexOf(`--${name}`);
|
|
364
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
365
|
+
};
|
|
366
|
+
const quoteId = flag('quote-id') || 'q_local_test';
|
|
367
|
+
const policyId = flag('policy-id') || 'POL-LOCAL-TEST';
|
|
368
|
+
const startsAt = new Date(Date.now() + 86_400_000).toISOString();
|
|
369
|
+
const endsAt = new Date(Date.now() + 100_800_000).toISOString();
|
|
370
|
+
switch (endpoint) {
|
|
371
|
+
case 'quote':
|
|
372
|
+
return {
|
|
373
|
+
productCode: flag('product') || 'hull-basic',
|
|
374
|
+
assetCategory: 'pontoon',
|
|
375
|
+
assetValue: 48000,
|
|
376
|
+
units: 1,
|
|
377
|
+
startsAt,
|
|
378
|
+
endsAt,
|
|
379
|
+
days: Number(flag('days') || 1),
|
|
380
|
+
bookingSubtotal: Number(flag('subtotal') || 850),
|
|
381
|
+
securityDeposit: 2000,
|
|
382
|
+
currency: flag('currency') || 'USD',
|
|
383
|
+
region: flag('region') || null,
|
|
384
|
+
};
|
|
385
|
+
case 'schema':
|
|
386
|
+
return { version: flag('version') || '2026-09-01' };
|
|
387
|
+
case 'requote':
|
|
388
|
+
return {
|
|
389
|
+
quoteId,
|
|
390
|
+
basePremium: Number(flag('premium') || 55.25),
|
|
391
|
+
answers: JSON.parse(flag('answers') || '{"date_of_birth":"1988-03-14","prior_claims_3y":false}'),
|
|
392
|
+
};
|
|
393
|
+
case 'bind':
|
|
394
|
+
return {
|
|
395
|
+
quoteId,
|
|
396
|
+
bookingReference: flag('booking') || 'bkg_local_test',
|
|
397
|
+
premiumCharged: Number(flag('premium') || 55.25),
|
|
398
|
+
renter: {
|
|
399
|
+
firstName: 'Sam',
|
|
400
|
+
lastName: 'Okafor',
|
|
401
|
+
email: 'sam@example.invalid',
|
|
402
|
+
phone: '+14165550142',
|
|
403
|
+
dateOfBirth: '1988-03-14',
|
|
404
|
+
},
|
|
405
|
+
answers: JSON.parse(flag('answers') || '{"date_of_birth":"1988-03-14"}'),
|
|
406
|
+
};
|
|
407
|
+
case 'reschedule':
|
|
408
|
+
return {
|
|
409
|
+
policyId,
|
|
410
|
+
bookingReference: flag('booking') || 'bkg_local_test',
|
|
411
|
+
previousStartsAt: startsAt,
|
|
412
|
+
previousEndsAt: endsAt,
|
|
413
|
+
startsAt: new Date(Date.now() + 8 * 86_400_000).toISOString(),
|
|
414
|
+
endsAt: new Date(Date.now() + 8 * 86_400_000 + 14_400_000).toISOString(),
|
|
415
|
+
days: 1,
|
|
416
|
+
};
|
|
417
|
+
case 'cancel':
|
|
418
|
+
return { policyId, reason: flag('reason') || 'booking_cancelled' };
|
|
419
|
+
default:
|
|
420
|
+
return {};
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const ENDPOINTS = ['quote', 'schema', 'requote', 'bind', 'reschedule', 'cancel'];
|
|
424
|
+
async function callOne(endpoint, args) {
|
|
425
|
+
const config = requireCoverageConfig();
|
|
426
|
+
if (!config.providerId)
|
|
427
|
+
fail('Not linked yet.', 'Run `rentaltide coverage link`.');
|
|
428
|
+
const urlFlag = args.indexOf('--url');
|
|
429
|
+
const baseUrl = (urlFlag >= 0 ? args[urlFlag + 1] : undefined) || config.dev?.baseUrl || config.baseUrl;
|
|
430
|
+
const signingSecret = readSigningSecret(config.providerId);
|
|
431
|
+
if (!signingSecret) {
|
|
432
|
+
fail('No signing secret for this offering.', 'Run `rentaltide coverage secret`.');
|
|
433
|
+
}
|
|
434
|
+
// `--data` REPLACES the default body rather than merging into it: a merge
|
|
435
|
+
// would quietly leave defaults in a payload somebody wrote deliberately.
|
|
436
|
+
const dataFlag = args.indexOf('--data');
|
|
437
|
+
let body;
|
|
438
|
+
if (dataFlag >= 0) {
|
|
439
|
+
try {
|
|
440
|
+
body = JSON.parse(args[dataFlag + 1] || '{}');
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
return fail('--data is not valid JSON.', err instanceof Error ? err.message : undefined);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
try {
|
|
448
|
+
body = defaultBody(endpoint, args);
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
return fail('An option was not valid JSON.', err instanceof Error ? err.message : undefined);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
say(`${bold('POST')} ${baseUrl.replace(/\/$/, '')}/${endpoint}`);
|
|
455
|
+
say(dim(JSON.stringify(body, null, 2)));
|
|
456
|
+
say();
|
|
457
|
+
const result = await call({ baseUrl, secret: signingSecret }, `/${endpoint}`, body, endpoint === 'bind' ? 15000 : 10000, endpoint === 'bind' ? String(body.quoteId ?? '') : undefined);
|
|
458
|
+
if (result.error)
|
|
459
|
+
fail(result.error);
|
|
460
|
+
const tone = result.status === 200 ? green : red;
|
|
461
|
+
say(`${tone(String(result.status))} ${dim(`${result.ms}ms`)}`);
|
|
462
|
+
say(JSON.stringify(result.body, null, 2));
|
|
463
|
+
if (result.status !== 200)
|
|
464
|
+
process.exit(1);
|
|
465
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
|
-
import { writeAppConfig, writeCoverageConfig } from '../config.js';
|
|
11
|
+
import { writeAppConfig, writeCoverageConfig, } from '../config.js';
|
|
12
12
|
import { ask, bold, dim, fail, ok, say } from '../ui.js';
|
|
13
13
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
function templateDir(name) {
|
package/dist/config.js
CHANGED
|
@@ -76,9 +76,7 @@ export function requireCoverageConfig(cwd = process.cwd()) {
|
|
|
76
76
|
return config;
|
|
77
77
|
}
|
|
78
78
|
export function readSigningSecret(providerId) {
|
|
79
|
-
return (process.env.RENTALTIDE_SIGNING_SECRET ||
|
|
80
|
-
readCredentials()?.signingSecrets?.[providerId] ||
|
|
81
|
-
null);
|
|
79
|
+
return (process.env.RENTALTIDE_SIGNING_SECRET || readCredentials()?.signingSecrets?.[providerId] || null);
|
|
82
80
|
}
|
|
83
81
|
export function saveSigningSecret(providerId, secret) {
|
|
84
82
|
const credentials = readCredentials();
|
package/dist/coverageProbe.js
CHANGED
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
* bind that writes a second policy on retry.
|
|
14
14
|
*/
|
|
15
15
|
import crypto from 'node:crypto';
|
|
16
|
-
function sign(secret, timestamp, body) {
|
|
16
|
+
export function sign(secret, timestamp, body) {
|
|
17
17
|
return `v1=${crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')}`;
|
|
18
18
|
}
|
|
19
|
-
async function call(opts, path, payload, timeoutMs, idempotencyKey) {
|
|
19
|
+
export async function call(opts, path, payload, timeoutMs, idempotencyKey) {
|
|
20
20
|
const raw = JSON.stringify(payload);
|
|
21
21
|
const timestamp = new Date().toISOString();
|
|
22
22
|
const started = Date.now();
|
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.
|
|
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
|
+
"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
|
|
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
|
|
29
|
-
|
|
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
|
|
|
@@ -1,221 +1,109 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* __APP_NAME__ — a RentalTide coverage underwriter.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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:
|
|
50
|
+
questionSchemaVersion: '2026-09-01',
|
|
119
51
|
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 &&
|
|
130
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
109
|
+
rentaltide.listen(Number(process.env.PORT) || 8787);
|