@rentaltide/cli 0.1.1 → 0.2.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 CHANGED
@@ -36,6 +36,40 @@ installed points all of them at your laptop.
36
36
  | `webhook trigger <event>` | Send yourself a signed webhook |
37
37
  | `open [booking\|portal\|docs]` | Open a sandbox surface |
38
38
 
39
+ ## Embedded insurance
40
+
41
+ An underwriter builds no iframe and holds no OAuth token — RentalTide calls
42
+ *them*. Different world, same CLI:
43
+
44
+ ```bash
45
+ rentaltide init --underwriter acme-cover
46
+ cd acme-cover
47
+ export SIGNING_SECRET=whsec_… # shown once when you created the offering
48
+ npm run dev # six endpoints on :8787
49
+
50
+ rentaltide coverage link
51
+ rentaltide coverage secret
52
+ rentaltide coverage test --url http://localhost:8787
53
+ ```
54
+
55
+ `coverage test` signs its requests exactly as the platform does and checks the
56
+ rules that fail *quietly*:
57
+
58
+ ```
59
+ ✓ Refuses an unsigned request
60
+ ✓ Quote answers inside 800ms 2ms
61
+ ✓ Premium is a JSON number
62
+ ✓ Deposit effect is coherent
63
+ ✓ A decline is a 200 with decision: declined
64
+ ✓ Bind is idempotent on quoteId
65
+ ✓ Policy document is https
66
+ ```
67
+
68
+ Then `coverage dev --url https://…` points the offering at a deployed endpoint,
69
+ `coverage status` says what is still missing, and `coverage submit` sends it for
70
+ review. `dev` refuses on an approved offering — repointing its base URL sends
71
+ real renters' quotes somewhere else.
72
+
39
73
  ## `rentaltide.app.json`
40
74
 
41
75
  Scopes and embed locations decide what your app may read and where it renders.
@@ -0,0 +1,273 @@
1
+ /**
2
+ * The underwriter's side of the CLI.
3
+ *
4
+ * A coverage partner builds no iframe and holds no OAuth token — we call them.
5
+ * So `dev` in the app sense is meaningless here, and what matters instead is
6
+ * being able to exercise the contract before a booking exists: sign a request
7
+ * the way we sign it, and be told which of the quiet rules you are breaking.
8
+ */
9
+ import { request } from '../api.js';
10
+ import { readSigningSecret, requireCoverageConfig, readCoverageConfig, saveSigningSecret, writeCoverageConfig, COVERAGE_CONFIG_FILENAME, } from '../config.js';
11
+ import { probe } from '../coverageProbe.js';
12
+ import { ask, bold, confirm, cyan, dim, fail, green, ok, red, say, step, warn } from '../ui.js';
13
+ const toConfig = (provider, existing) => ({
14
+ providerId: provider.id,
15
+ name: provider.name,
16
+ baseUrl: provider.baseUrl,
17
+ supportEmail: provider.supportEmail ?? undefined,
18
+ countries: provider.supportedCountries || [],
19
+ currencies: provider.supportedCurrencies || [],
20
+ quoteTimeoutMs: provider.quoteTimeoutMs,
21
+ ...(existing?.dev ? { dev: existing.dev } : {}),
22
+ });
23
+ async function listProviders() {
24
+ const data = await request('/developer/coverage-providers');
25
+ return data.providers || [];
26
+ }
27
+ async function getProvider(providerId) {
28
+ const providers = await listProviders();
29
+ const found = providers.find((p) => p.id === providerId);
30
+ if (!found)
31
+ fail('That offering is not on your account any more.', 'Run `rentaltide coverage link`.');
32
+ return found;
33
+ }
34
+ export async function coverage(args) {
35
+ const [sub, ...rest] = args;
36
+ switch (sub) {
37
+ case 'link':
38
+ return link();
39
+ case 'pull':
40
+ return pull();
41
+ case 'push':
42
+ return push(rest);
43
+ case 'test':
44
+ return test(rest);
45
+ case 'dev':
46
+ return dev(rest);
47
+ case 'secret':
48
+ return secret();
49
+ case 'status':
50
+ return status();
51
+ case 'submit':
52
+ return submit();
53
+ default:
54
+ say(`${bold('rentaltide coverage')} <command>`);
55
+ say();
56
+ say(' link attach this directory to one of your coverage offerings');
57
+ say(' pull write the offering into rentaltide.coverage.json');
58
+ say(' push apply rentaltide.coverage.json to the offering');
59
+ say(' test call your endpoints exactly as RentalTide does');
60
+ say(' dev point the offering at a URL while you work, restore on exit');
61
+ say(' secret store the signing secret for this machine');
62
+ say(' status what still has to be true before review');
63
+ say(' submit submit the offering for review');
64
+ }
65
+ }
66
+ async function link() {
67
+ const providers = await listProviders();
68
+ if (providers.length === 0) {
69
+ fail('You have no coverage offerings yet.', 'Create one at partners.rentaltide.com → Developer → Embedded insurance.');
70
+ }
71
+ let chosen = providers[0];
72
+ if (providers.length > 1) {
73
+ providers.forEach((p, i) => say(` ${bold(String(i + 1))}. ${p.name} ${dim(`(${p.status})`)}`));
74
+ const answer = await ask(`Which one? ${dim('1-' + providers.length)}`);
75
+ chosen = providers[Number(answer) - 1];
76
+ if (!chosen)
77
+ fail('That is not one of the options.');
78
+ }
79
+ writeCoverageConfig(toConfig(chosen, readCoverageConfig()));
80
+ ok(`Linked to ${chosen.name}. Wrote ${COVERAGE_CONFIG_FILENAME}.`);
81
+ if (!readSigningSecret(chosen.id)) {
82
+ say(dim('Run `rentaltide coverage secret` to store your signing secret for testing.'));
83
+ }
84
+ }
85
+ async function pull() {
86
+ const config = requireCoverageConfig();
87
+ if (!config.providerId)
88
+ fail('Not linked yet.', 'Run `rentaltide coverage link`.');
89
+ const provider = await getProvider(config.providerId);
90
+ writeCoverageConfig(toConfig(provider, config));
91
+ ok(`Pulled ${provider.name} into ${COVERAGE_CONFIG_FILENAME}.`);
92
+ }
93
+ async function push(args) {
94
+ const config = requireCoverageConfig();
95
+ if (!config.providerId)
96
+ fail('Not linked yet.', 'Run `rentaltide coverage link`.');
97
+ const before = toConfig(await getProvider(config.providerId));
98
+ const fields = [
99
+ ['name', before.name, config.name],
100
+ ['baseUrl', before.baseUrl, config.baseUrl],
101
+ ['supportEmail', before.supportEmail, config.supportEmail],
102
+ ['countries', before.countries.join(', '), config.countries.join(', ')],
103
+ ['currencies', before.currencies.join(', '), config.currencies.join(', ')],
104
+ ['quoteTimeoutMs', before.quoteTimeoutMs, config.quoteTimeoutMs],
105
+ ];
106
+ const changed = fields.filter(([, a, b]) => String(a ?? '') !== String(b ?? ''));
107
+ if (changed.length === 0) {
108
+ ok('Nothing to push.');
109
+ return;
110
+ }
111
+ say(`${bold('Changes to')} ${before.name}:`);
112
+ for (const [field, a, b] of changed) {
113
+ say(` ${field}`);
114
+ say(` ${red('-')} ${dim(String(a ?? '(none)'))}`);
115
+ say(` ${green('+')} ${String(b ?? '(none)')}`);
116
+ }
117
+ // The three that send an approved offering back for review, said before it
118
+ // happens rather than discovered from a status badge afterwards.
119
+ const material = changed.filter(([field]) => ['baseUrl', 'countries', 'currencies'].includes(field));
120
+ if (material.length > 0) {
121
+ say();
122
+ warn('baseUrl, countries and currencies decide where renter data and money go. Changing one sends an approved offering back for review (it keeps working in sandbox).');
123
+ }
124
+ say();
125
+ if (!args.includes('--yes') && !(await confirm('Apply?'))) {
126
+ say('Nothing pushed.');
127
+ return;
128
+ }
129
+ await request(`/developer/coverage-providers/${config.providerId}`, {
130
+ method: 'PATCH',
131
+ body: {
132
+ name: config.name,
133
+ baseUrl: config.baseUrl,
134
+ supportEmail: config.supportEmail,
135
+ supportedCountries: config.countries,
136
+ supportedCurrencies: config.currencies,
137
+ quoteTimeoutMs: config.quoteTimeoutMs,
138
+ },
139
+ });
140
+ ok('Pushed.');
141
+ }
142
+ async function secret() {
143
+ const config = requireCoverageConfig();
144
+ if (!config.providerId)
145
+ fail('Not linked yet.', 'Run `rentaltide coverage link`.');
146
+ say(dim('RentalTide shows a signing secret once, at creation, and cannot return it again.'));
147
+ const value = await ask('Signing secret:', { mask: true });
148
+ if (!value.startsWith('whsec_')) {
149
+ warn('That does not look like a signing secret (they start with whsec_). Saved anyway.');
150
+ }
151
+ saveSigningSecret(config.providerId, value);
152
+ ok('Saved to ~/.rentaltide/credentials.json — this machine only, never the project.');
153
+ }
154
+ async function test(args) {
155
+ const config = requireCoverageConfig();
156
+ if (!config.providerId)
157
+ fail('Not linked yet.', 'Run `rentaltide coverage link`.');
158
+ const urlFlag = args.indexOf('--url');
159
+ const baseUrl = (urlFlag >= 0 ? args[urlFlag + 1] : undefined) || config.dev?.baseUrl || config.baseUrl;
160
+ if (!baseUrl)
161
+ fail('No URL to test.', 'Add dev.baseUrl to rentaltide.coverage.json or pass --url.');
162
+ const signingSecret = readSigningSecret(config.providerId);
163
+ if (!signingSecret) {
164
+ fail('No signing secret for this offering.', 'Run `rentaltide coverage secret`, or set RENTALTIDE_SIGNING_SECRET.');
165
+ }
166
+ step(`Calling ${bold(baseUrl)} the way RentalTide does`);
167
+ say();
168
+ const results = await probe({
169
+ baseUrl,
170
+ secret: signingSecret,
171
+ currency: config.currencies[0] || 'USD',
172
+ quoteTimeoutMs: config.quoteTimeoutMs,
173
+ });
174
+ for (const result of results) {
175
+ const mark = result.ok ? green('✓') : red('✗');
176
+ const timing = result.ms !== undefined ? dim(` ${result.ms}ms`) : '';
177
+ say(` ${mark} ${result.name}${timing}`);
178
+ if (result.detail)
179
+ say(` ${dim(result.detail)}`);
180
+ }
181
+ const failed = results.filter((r) => !r.ok);
182
+ say();
183
+ if (failed.length === 0) {
184
+ ok(`All ${results.length} checks passed. This endpoint can serve a renter.`);
185
+ return;
186
+ }
187
+ // Exit non-zero so this is usable in CI.
188
+ say(`${red(`${failed.length} of ${results.length} checks failed.`)}`);
189
+ say(dim('Full contract: https://docs.rentaltide.com/developers/embedded-insurance/'));
190
+ process.exit(1);
191
+ }
192
+ async function dev(args) {
193
+ const config = requireCoverageConfig();
194
+ if (!config.providerId)
195
+ fail('Not linked yet.', 'Run `rentaltide coverage link`.');
196
+ const urlFlag = args.indexOf('--url');
197
+ const target = (urlFlag >= 0 ? args[urlFlag + 1] : undefined) || config.dev?.baseUrl;
198
+ if (!target) {
199
+ fail('No URL to point at.', 'Pass --url https://…, or set dev.baseUrl in the config.');
200
+ }
201
+ if (!target.startsWith('https://')) {
202
+ // Not a preference: the platform rejects a plaintext base URL outright,
203
+ // because renter identity is sent to it at bind.
204
+ fail('A base URL must be https.', 'We sign every call to it and send renter identity at bind.');
205
+ }
206
+ const provider = await getProvider(config.providerId);
207
+ if (provider.status === 'approved') {
208
+ fail(`${provider.name} is approved and may be live in merchants' checkouts.`, 'Repointing its base URL sends real renters\u2019 quotes to your test endpoint.');
209
+ }
210
+ const previous = provider.baseUrl;
211
+ step(`Pointing ${provider.name} at ${bold(target)}`);
212
+ await request(`/developer/coverage-providers/${config.providerId}`, {
213
+ method: 'PATCH',
214
+ body: { baseUrl: target },
215
+ });
216
+ let restored = false;
217
+ const restore = async () => {
218
+ if (restored)
219
+ return;
220
+ restored = true;
221
+ try {
222
+ await request(`/developer/coverage-providers/${config.providerId}`, {
223
+ method: 'PATCH',
224
+ body: { baseUrl: previous },
225
+ });
226
+ say(`\n${dim(`Restored base URL to ${previous}.`)}`);
227
+ }
228
+ catch {
229
+ warn(`Could not restore the base URL. Set it back to ${previous} in the portal.`);
230
+ }
231
+ };
232
+ process.on('SIGINT', () => void restore().then(() => process.exit(0)));
233
+ process.on('SIGTERM', () => void restore().then(() => process.exit(0)));
234
+ say();
235
+ ok(`${provider.name} now quotes from ${target}.`);
236
+ say(` ${dim('Sandbox locations only until the offering is approved.')}`);
237
+ say(` ${cyan('rentaltide coverage test')} ${dim('exercises it without a booking.')}`);
238
+ say();
239
+ say(dim('Ctrl-C restores the base URL.'));
240
+ await new Promise(() => { });
241
+ }
242
+ async function status() {
243
+ const config = requireCoverageConfig();
244
+ if (!config.providerId)
245
+ fail('Not linked yet.', 'Run `rentaltide coverage link`.');
246
+ const provider = await getProvider(config.providerId);
247
+ const checks = [
248
+ [Boolean(provider.baseUrl), 'An https endpoint for us to call'],
249
+ [provider.supportedCountries.length > 0, 'At least one country you are licensed to write in'],
250
+ [provider.supportedCurrencies.length > 0, 'The currencies you price in'],
251
+ [Boolean(provider.stripeAccountId), 'A connected payout account'],
252
+ [Boolean(provider.supportEmail), 'A support address renters can reply to'],
253
+ [provider.isActive, 'Switched on'],
254
+ ];
255
+ say(`${bold(provider.name)} ${dim(`· ${provider.status}`)}`);
256
+ if (provider.rejectionReason)
257
+ say(` ${red(provider.rejectionReason)}`);
258
+ say();
259
+ for (const [done, label] of checks) {
260
+ say(` ${done ? green('✓') : dim('○')} ${label}`);
261
+ }
262
+ say();
263
+ say(provider.status === 'approved'
264
+ ? dim('Approved. Merchants can enable it for their locations.')
265
+ : dim('Sandbox locations only until approved. `rentaltide coverage submit` when ready.'));
266
+ }
267
+ async function submit() {
268
+ const config = requireCoverageConfig();
269
+ if (!config.providerId)
270
+ fail('Not linked yet.', 'Run `rentaltide coverage link`.');
271
+ await request(`/developer/coverage-providers/${config.providerId}/submit`, { method: 'POST' });
272
+ ok('Submitted for review. It keeps working in sandbox meanwhile.');
273
+ }
@@ -8,19 +8,19 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
- import { writeAppConfig } 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
- function templateDir() {
14
+ function templateDir(name) {
15
15
  // dist/commands/init.js → package root → templates
16
16
  const candidates = [
17
- path.resolve(here, '../../templates/starter'),
18
- path.resolve(here, '../templates/starter'),
17
+ path.resolve(here, `../../templates/${name}`),
18
+ path.resolve(here, `../templates/${name}`),
19
19
  ];
20
20
  for (const dir of candidates)
21
21
  if (fs.existsSync(dir))
22
22
  return dir;
23
- return fail('The starter template is missing from this install of the CLI.');
23
+ return fail(`The ${name} template is missing from this install of the CLI.`);
24
24
  }
25
25
  function copyTree(from, to, replace) {
26
26
  fs.mkdirSync(to, { recursive: true });
@@ -37,6 +37,9 @@ function copyTree(from, to, replace) {
37
37
  }
38
38
  }
39
39
  export async function init(args) {
40
+ // An underwriter builds something structurally different: no iframe, no
41
+ // scopes, six endpoints we call. Same command, different world.
42
+ const underwriter = args.includes('--underwriter') || args.includes('--coverage');
40
43
  const target = args.find((a) => !a.startsWith('-'));
41
44
  const name = (await ask(`App name: ${dim('(e.g. Dock Weather)')}`)) || 'My RentalTide App';
42
45
  const slug = (await ask(`Slug: ${dim(slugify(name))}`)) || slugify(name);
@@ -44,7 +47,30 @@ export async function init(args) {
44
47
  if (fs.existsSync(dir) && fs.readdirSync(dir).length > 0) {
45
48
  fail(`${dir} already exists and is not empty.`);
46
49
  }
47
- copyTree(templateDir(), dir, (body) => body.replaceAll('__APP_NAME__', name).replaceAll('__APP_SLUG__', slug));
50
+ copyTree(templateDir(underwriter ? 'underwriter' : 'starter'), dir, (body) => body.replaceAll('__APP_NAME__', name).replaceAll('__APP_SLUG__', slug));
51
+ const where = path.relative(process.cwd(), dir) || '.';
52
+ if (underwriter) {
53
+ const config = {
54
+ name,
55
+ baseUrl: 'https://example.com',
56
+ countries: ['US'],
57
+ currencies: ['USD'],
58
+ quoteTimeoutMs: 800,
59
+ dev: { baseUrl: 'http://localhost:8787' },
60
+ };
61
+ writeCoverageConfig(config, dir);
62
+ ok(`Created ${bold(where)}`);
63
+ say();
64
+ say('Next:');
65
+ say(` ${bold(`cd ${where}`)}`);
66
+ say(` ${bold('export SIGNING_SECRET=whsec_…')} ${dim('shown once when you created the offering')}`);
67
+ say(` ${bold('npm run dev')}`);
68
+ say(` ${bold('rentaltide login')}`);
69
+ say(` ${bold('rentaltide coverage link')} ${dim('attach this to your offering')}`);
70
+ say(` ${bold('rentaltide coverage secret')} ${dim('store the secret for testing')}`);
71
+ say(` ${bold('rentaltide coverage test')} ${dim('call it the way RentalTide does')}`);
72
+ return;
73
+ }
48
74
  const config = {
49
75
  name,
50
76
  slug,
@@ -56,10 +82,10 @@ export async function init(args) {
56
82
  dev: { embedUrl: 'http://localhost:5173' },
57
83
  };
58
84
  writeAppConfig(config, dir);
59
- ok(`Created ${bold(path.relative(process.cwd(), dir) || '.')}`);
85
+ ok(`Created ${bold(where)}`);
60
86
  say();
61
87
  say('Next:');
62
- say(` ${bold(`cd ${path.relative(process.cwd(), dir) || '.'}`)}`);
88
+ say(` ${bold(`cd ${where}`)}`);
63
89
  say(` ${bold('npm install')}`);
64
90
  say(` ${bold('rentaltide login')} ${dim('once per machine')}`);
65
91
  say(` ${bold('rentaltide link')} ${dim('pick the app this belongs to')}`);
package/dist/config.js CHANGED
@@ -14,6 +14,7 @@ import fs from 'node:fs';
14
14
  import path from 'node:path';
15
15
  import os from 'node:os';
16
16
  export const CONFIG_FILENAME = 'rentaltide.app.json';
17
+ export const COVERAGE_CONFIG_FILENAME = 'rentaltide.coverage.json';
17
18
  export const DEFAULT_API = 'https://v3.api.rentaltide.com';
18
19
  const CREDENTIALS_DIR = path.join(os.homedir(), '.rentaltide');
19
20
  const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, 'credentials.json');
@@ -53,6 +54,41 @@ export function readAppConfig(cwd = process.cwd()) {
53
54
  export function writeAppConfig(config, cwd = process.cwd()) {
54
55
  fs.writeFileSync(configPath(cwd), `${JSON.stringify(config, null, 2)}\n`);
55
56
  }
57
+ export function coverageConfigPath(cwd = process.cwd()) {
58
+ return path.join(cwd, COVERAGE_CONFIG_FILENAME);
59
+ }
60
+ export function readCoverageConfig(cwd = process.cwd()) {
61
+ try {
62
+ return JSON.parse(fs.readFileSync(coverageConfigPath(cwd), 'utf8'));
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ export function writeCoverageConfig(config, cwd = process.cwd()) {
69
+ fs.writeFileSync(coverageConfigPath(cwd), `${JSON.stringify(config, null, 2)}\n`);
70
+ }
71
+ export function requireCoverageConfig(cwd = process.cwd()) {
72
+ const config = readCoverageConfig(cwd);
73
+ if (!config) {
74
+ throw new Error(`No ${COVERAGE_CONFIG_FILENAME} here. Run \`rentaltide init --underwriter\` to start one, or \`rentaltide coverage link\` in an existing project.`);
75
+ }
76
+ return config;
77
+ }
78
+ export function readSigningSecret(providerId) {
79
+ return (process.env.RENTALTIDE_SIGNING_SECRET ||
80
+ readCredentials()?.signingSecrets?.[providerId] ||
81
+ null);
82
+ }
83
+ export function saveSigningSecret(providerId, secret) {
84
+ const credentials = readCredentials();
85
+ if (!credentials)
86
+ throw new Error('Sign in first: `rentaltide login`.');
87
+ writeCredentials({
88
+ ...credentials,
89
+ signingSecrets: { ...(credentials.signingSecrets || {}), [providerId]: secret },
90
+ });
91
+ }
56
92
  /** The config, or a message telling them exactly what to run. */
57
93
  export function requireAppConfig(cwd = process.cwd()) {
58
94
  const config = readAppConfig(cwd);
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Call an underwriter exactly the way RentalTide does.
3
+ *
4
+ * The signature is the one part of this integration that cannot be checked by
5
+ * reading the docs, and before this the only way to exercise it was to make a
6
+ * real booking happen. Everything here mirrors
7
+ * `services/coverages/providerClient.ts`: the same header names, the same
8
+ * signed payload (`{timestamp}.{raw body}`), the same idempotency key on bind,
9
+ * the same deadlines.
10
+ *
11
+ * It is deliberately picky about the things that are silently wrong rather than
12
+ * loud: a premium sent as a string, a reducing deposit effect with no number, a
13
+ * bind that writes a second policy on retry.
14
+ */
15
+ import crypto from 'node:crypto';
16
+ function sign(secret, timestamp, body) {
17
+ return `v1=${crypto.createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')}`;
18
+ }
19
+ async function call(opts, path, payload, timeoutMs, idempotencyKey) {
20
+ const raw = JSON.stringify(payload);
21
+ const timestamp = new Date().toISOString();
22
+ const started = Date.now();
23
+ try {
24
+ const response = await fetch(`${opts.baseUrl.replace(/\/$/, '')}${path}`, {
25
+ method: 'POST',
26
+ headers: {
27
+ 'content-type': 'application/json',
28
+ 'X-RentalTide-Signature': sign(opts.secret, timestamp, raw),
29
+ 'X-RentalTide-Timestamp': timestamp,
30
+ ...(idempotencyKey ? { 'X-RentalTide-Idempotency-Key': idempotencyKey } : {}),
31
+ },
32
+ body: raw,
33
+ signal: AbortSignal.timeout(timeoutMs),
34
+ });
35
+ const text = await response.text();
36
+ let body = null;
37
+ try {
38
+ body = text ? JSON.parse(text) : null;
39
+ }
40
+ catch {
41
+ body = { __unparseable: text.slice(0, 300) };
42
+ }
43
+ return { status: response.status, body, ms: Date.now() - started };
44
+ }
45
+ catch (err) {
46
+ const message = err instanceof Error ? err.message : String(err);
47
+ return {
48
+ status: 0,
49
+ body: null,
50
+ ms: Date.now() - started,
51
+ error: /timeout|abort/i.test(message) ? `timed out after ${timeoutMs}ms` : message,
52
+ };
53
+ }
54
+ }
55
+ /** An unsigned call, to prove the endpoint refuses one. */
56
+ async function callUnsigned(opts, path, payload) {
57
+ try {
58
+ const response = await fetch(`${opts.baseUrl.replace(/\/$/, '')}${path}`, {
59
+ method: 'POST',
60
+ headers: { 'content-type': 'application/json' },
61
+ body: JSON.stringify(payload),
62
+ signal: AbortSignal.timeout(5000),
63
+ });
64
+ return response.status;
65
+ }
66
+ catch {
67
+ return 0;
68
+ }
69
+ }
70
+ const money = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
71
+ export async function probe(opts) {
72
+ const results = [];
73
+ const currency = opts.currency || 'USD';
74
+ const subtotal = opts.subtotal ?? 850;
75
+ const quoteDeadline = opts.quoteTimeoutMs ?? 800;
76
+ const starts = new Date(Date.now() + 86_400_000).toISOString();
77
+ const ends = new Date(Date.now() + 100_800_000).toISOString();
78
+ // ── 1. An unsigned request must be refused ────────────────────────────────
79
+ const unsignedStatus = await callUnsigned(opts, '/quote', { bookingSubtotal: subtotal });
80
+ results.push({
81
+ name: 'Refuses an unsigned request',
82
+ ok: unsignedStatus === 401 || unsignedStatus === 403,
83
+ detail: unsignedStatus === 0
84
+ ? 'no response'
85
+ : unsignedStatus === 200
86
+ ? 'answered 200 — anyone on the internet can quote, requote and bind as us'
87
+ : `answered ${unsignedStatus}`,
88
+ });
89
+ // ── 2. Quote ──────────────────────────────────────────────────────────────
90
+ const quoteRequest = {
91
+ productCode: 'hull-basic',
92
+ assetCategory: 'pontoon',
93
+ assetValue: 48000,
94
+ units: 1,
95
+ startsAt: starts,
96
+ endsAt: ends,
97
+ days: 1,
98
+ bookingSubtotal: subtotal,
99
+ securityDeposit: 2000,
100
+ currency,
101
+ region: null,
102
+ };
103
+ const quote = await call(opts, '/quote', quoteRequest, Math.max(quoteDeadline, 2000));
104
+ const quoteBody = (quote.body || {});
105
+ results.push({
106
+ name: 'POST /quote answers',
107
+ ok: quote.status === 200,
108
+ detail: quote.error || `HTTP ${quote.status}`,
109
+ request: quoteRequest,
110
+ response: quote.body,
111
+ ms: quote.ms,
112
+ });
113
+ const quoted = quote.status === 200 && typeof quoteBody.quoteId === 'string';
114
+ if (quoted) {
115
+ results.push({
116
+ name: `Quote answers inside ${quoteDeadline}ms`,
117
+ ok: quote.ms <= quoteDeadline,
118
+ detail: `${quote.ms}ms — a slower quote does not render, and the booking proceeds without you`,
119
+ ms: quote.ms,
120
+ });
121
+ results.push({
122
+ name: 'Premium is a JSON number',
123
+ ok: money(quoteBody.premium),
124
+ detail: typeof quoteBody.premium === 'string'
125
+ ? `sent "${quoteBody.premium}" as a string — rejected`
126
+ : `premium: ${String(quoteBody.premium)}`,
127
+ });
128
+ const effect = String(quoteBody.depositEffect ?? 'none');
129
+ const reducing = effect !== 'none' && effect !== 'waive';
130
+ results.push({
131
+ name: 'Deposit effect is coherent',
132
+ ok: !reducing || money(quoteBody.depositEffectValue),
133
+ detail: reducing
134
+ ? `${effect} with depositEffectValue ${String(quoteBody.depositEffectValue)}`
135
+ : effect,
136
+ });
137
+ results.push({
138
+ name: 'Quote carries a schema version',
139
+ ok: typeof quoteBody.questionSchemaVersion === 'string',
140
+ detail: String(quoteBody.questionSchemaVersion ?? '(none — no questions will be asked)'),
141
+ });
142
+ }
143
+ // ── 3. Schema ─────────────────────────────────────────────────────────────
144
+ const version = String(quoteBody.questionSchemaVersion ?? '');
145
+ if (version) {
146
+ const schema = await call(opts, '/schema', { version }, 5000);
147
+ const schemaBody = (schema.body || {});
148
+ const fields = Array.isArray(schemaBody.fields) ? schemaBody.fields : [];
149
+ results.push({
150
+ name: 'POST /schema answers with fields',
151
+ ok: schema.status === 200 && fields.length > 0,
152
+ detail: schema.error || `HTTP ${schema.status} · ${fields.length} field(s)`,
153
+ response: schema.body,
154
+ ms: schema.ms,
155
+ });
156
+ results.push({
157
+ name: 'At most 12 questions',
158
+ ok: fields.length <= 12,
159
+ detail: `${fields.length} — each one is friction at the moment somebody is deciding to pay`,
160
+ });
161
+ }
162
+ // ── 4. Requote ────────────────────────────────────────────────────────────
163
+ const quoteId = String(quoteBody.quoteId ?? '');
164
+ if (quoteId) {
165
+ const accepted = await call(opts, '/requote', {
166
+ quoteId,
167
+ basePremium: quoteBody.premium,
168
+ answers: { date_of_birth: '1988-03-14', prior_claims_3y: false },
169
+ }, 5000);
170
+ const acceptedBody = (accepted.body || {});
171
+ results.push({
172
+ name: 'POST /requote accepts a clean answer set',
173
+ ok: accepted.status === 200 && acceptedBody.decision === 'accepted',
174
+ detail: accepted.error || `HTTP ${accepted.status} · decision ${String(acceptedBody.decision)}`,
175
+ response: accepted.body,
176
+ ms: accepted.ms,
177
+ });
178
+ const declined = await call(opts, '/requote', { quoteId, basePremium: quoteBody.premium, answers: { date_of_birth: '2015-01-01' } }, 5000);
179
+ const declinedBody = (declined.body || {});
180
+ results.push({
181
+ name: 'A decline is a 200 with decision: declined',
182
+ ok: declined.status === 200 && ['declined', 'accepted'].includes(String(declinedBody.decision)),
183
+ detail: declined.status !== 200
184
+ ? `answered HTTP ${declined.status} — an error is not a decline, and drops the coverage silently`
185
+ : `decision ${String(declinedBody.decision)}`,
186
+ response: declined.body,
187
+ });
188
+ // ── 5. Bind, twice ──────────────────────────────────────────────────────
189
+ const bindRequest = {
190
+ quoteId,
191
+ bookingReference: `bkg_probe_${crypto.randomBytes(3).toString('hex')}`,
192
+ premiumCharged: quoteBody.premium,
193
+ renter: {
194
+ firstName: 'Probe',
195
+ lastName: 'Renter',
196
+ email: 'probe@example.invalid',
197
+ phone: '+14165550142',
198
+ dateOfBirth: '1988-03-14',
199
+ },
200
+ answers: { date_of_birth: '1988-03-14', prior_claims_3y: false },
201
+ };
202
+ const first = await call(opts, '/bind', bindRequest, 15000, quoteId);
203
+ const firstBody = (first.body || {});
204
+ results.push({
205
+ name: 'POST /bind issues a policy',
206
+ ok: first.status === 200 && typeof firstBody.policyId === 'string',
207
+ detail: first.error || `HTTP ${first.status} · policy ${String(firstBody.policyId)}`,
208
+ response: first.body,
209
+ ms: first.ms,
210
+ });
211
+ if (typeof firstBody.policyId === 'string') {
212
+ const second = await call(opts, '/bind', bindRequest, 15000, quoteId);
213
+ const secondBody = (second.body || {});
214
+ results.push({
215
+ name: 'Bind is idempotent on quoteId',
216
+ ok: secondBody.policyId === firstBody.policyId,
217
+ detail: secondBody.policyId === firstBody.policyId
218
+ ? `retry returned ${String(firstBody.policyId)} again`
219
+ : `retry issued ${String(secondBody.policyId)} — we retry on timeout, so this writes a second policy on the same booking`,
220
+ });
221
+ const documentUrl = String(firstBody.documentUrl ?? '');
222
+ results.push({
223
+ name: 'Policy document is https',
224
+ ok: !documentUrl || documentUrl.startsWith('https://'),
225
+ detail: documentUrl || '(none)',
226
+ });
227
+ // ── 6. Reschedule ─────────────────────────────────────────────────────
228
+ const moved = await call(opts, '/reschedule', {
229
+ policyId: firstBody.policyId,
230
+ bookingReference: bindRequest.bookingReference,
231
+ previousStartsAt: starts,
232
+ previousEndsAt: ends,
233
+ startsAt: new Date(Date.now() + 8 * 86_400_000).toISOString(),
234
+ endsAt: new Date(Date.now() + 8 * 86_400_000 + 14_400_000).toISOString(),
235
+ days: 1,
236
+ }, 10000);
237
+ results.push({
238
+ name: 'POST /reschedule answers',
239
+ ok: moved.status === 200,
240
+ detail: moved.error ||
241
+ `HTTP ${moved.status} · decision ${String(moved.body?.decision ?? 'accepted')}`,
242
+ response: moved.body,
243
+ });
244
+ // ── 7. Cancel ─────────────────────────────────────────────────────────
245
+ const cancelled = await call(opts, '/cancel', { policyId: firstBody.policyId, reason: 'probe' }, 10000);
246
+ results.push({
247
+ name: 'POST /cancel answers',
248
+ ok: cancelled.status === 200,
249
+ detail: cancelled.error || `HTTP ${cancelled.status}`,
250
+ response: cancelled.body,
251
+ });
252
+ }
253
+ }
254
+ return results;
255
+ }
package/dist/index.js CHANGED
@@ -14,13 +14,15 @@ import { dev } from './commands/dev.js';
14
14
  import { init } from './commands/init.js';
15
15
  import { webhook } from './commands/webhook.js';
16
16
  import { open } from './commands/open.js';
17
+ import { coverage } from './commands/coverage.js';
17
18
  import { bold, dim, say } from './ui.js';
18
- const VERSION = '0.1.1';
19
+ const VERSION = '0.2.0';
19
20
  function usage() {
20
21
  say(`${bold('rentaltide')} ${dim(VERSION)} — build apps for RentalTide`);
21
22
  say();
22
23
  say(bold(' Getting started'));
23
24
  say(` init [dir] scaffold an app that already runs`);
25
+ say(` init --underwriter scaffold a coverage underwriter instead`);
24
26
  say(` login sign in to your partner account`);
25
27
  say(` link attach this directory to one of your apps`);
26
28
  say();
@@ -31,6 +33,12 @@ function usage() {
31
33
  say(` webhook trigger <event> send yourself a signed webhook`);
32
34
  say(` open [booking|portal|docs]`);
33
35
  say();
36
+ say(bold(' Embedded insurance'));
37
+ say(` coverage link attach this directory to a coverage offering`);
38
+ say(` coverage test call your endpoints the way RentalTide does`);
39
+ say(` coverage dev point the offering at a URL while you work`);
40
+ say(` coverage status|submit|push|pull|secret`);
41
+ say();
34
42
  say(bold(' Other'));
35
43
  say(` whoami who you are signed in as`);
36
44
  say(` logout`);
@@ -60,6 +68,8 @@ async function main() {
60
68
  return webhook(args);
61
69
  case 'open':
62
70
  return open(args);
71
+ case 'coverage':
72
+ return coverage(args);
63
73
  case '--version':
64
74
  case '-v':
65
75
  say(VERSION);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rentaltide/cli",
3
- "version": "0.1.1",
4
- "description": "Build RentalTide apps: scaffold, run against your sandbox, push config, trigger webhooks.",
3
+ "version": "0.2.0",
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.",
7
7
  "homepage": "https://docs.rentaltide.com/developers/cli/",
@@ -37,7 +37,9 @@
37
37
  "rentaltide",
38
38
  "cli",
39
39
  "apps",
40
- "developer"
40
+ "developer",
41
+ "insurance",
42
+ "underwriter"
41
43
  ],
42
44
  "devDependencies": {
43
45
  "typescript": "^5.6.0",
@@ -0,0 +1,33 @@
1
+ # __APP_NAME__
2
+
3
+ A RentalTide coverage underwriter. Six endpoints, no dependencies.
4
+
5
+ ```bash
6
+ export SIGNING_SECRET=whsec_... # shown once when you created the offering
7
+ npm run dev # http://localhost:8787
8
+
9
+ rentaltide coverage link # attach this directory to the offering
10
+ rentaltide coverage secret # store the secret for testing
11
+ rentaltide coverage test --url http://localhost:8787
12
+ ```
13
+
14
+ `coverage test` signs its requests exactly as RentalTide does and checks the
15
+ rules that fail quietly: a premium sent as a string, a reducing deposit effect
16
+ with no value, a bind that writes a second policy on retry, a decline returned
17
+ as an error.
18
+
19
+ When it passes, point the offering at a deployed URL and run the whole renter
20
+ flow against your sandbox marina:
21
+
22
+ ```bash
23
+ rentaltide coverage dev --url https://your-deployed-underwriter.example.com
24
+ rentaltide coverage status # what is still missing before review
25
+ rentaltide coverage submit
26
+ ```
27
+
28
+ - `server.mjs` — your underwriting. Replace the pricing and the questions; keep
29
+ the signature check.
30
+ - `rentaltide.coverage.json` — base URL, licensed countries, currencies, quote
31
+ timeout. `push` applies it, `pull` brings it back.
32
+
33
+ Contract: <https://docs.rentaltide.com/developers/embedded-insurance/>
@@ -0,0 +1,3 @@
1
+ node_modules
2
+ .env
3
+ .DS_Store
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "__APP_SLUG__",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "start": "node server.mjs",
7
+ "dev": "node --watch server.mjs"
8
+ }
9
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * __APP_NAME__ — a RentalTide coverage underwriter.
3
+ *
4
+ * Six endpoints. RentalTide calls you; you never call a renter, never render a
5
+ * page, and never see a card number.
6
+ *
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
20
+ * Test it: rentaltide coverage test --url http://localhost:8787
21
+ */
22
+
23
+ import http from 'node:http';
24
+ import crypto from 'node:crypto';
25
+
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;
50
+
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 ───────────────────────────────────────────────────────
62
+
63
+ const BASE_RATE = 0.065;
64
+ const FLOOR = 12;
65
+ const MINIMUM_BOOKING = 50;
66
+
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' };
102
+
103
+ 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.
113
+ depositEffect: 'reduce_to',
114
+ depositEffectValue: 500,
115
+ displayName: '__APP_NAME__',
116
+ shortDescription: 'Covers hull damage up to $25,000. Drops your deposit to $500.',
117
+ terms: 'Excess $500 per incident. Operator must be 18 or over.',
118
+ questionSchemaVersion: SCHEMA_VERSION,
119
+ };
120
+ },
121
+
122
+ '/schema': () => QUESTIONS,
123
+
124
+ '/requote': (body) => {
125
+ const answers = body.answers || {};
126
+ const quoteId = String(body.quoteId || '');
127
+
128
+ 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
+ }
136
+ }
137
+
138
+ const base = Number(body.basePremium) || FLOOR;
139
+ const premium = answers.prior_claims_3y === true ? round2(base * 1.4) : base;
140
+
141
+ return {
142
+ quoteId,
143
+ decision: 'accepted',
144
+ premium,
145
+ finalUntil: new Date(Date.now() + 30 * 60_000).toISOString(),
146
+ };
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;
156
+
157
+ const policyId = `POL-${crypto.randomBytes(4).toString('hex').toUpperCase()}`;
158
+ const policy = {
159
+ policyId,
160
+ documentUrl: `https://example.com/policies/${policyId}.pdf`, // https, always
161
+ effectiveAt: new Date().toISOString(),
162
+ expiresAt: null,
163
+ };
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}`);
173
+ 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' });
197
+
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();
203
+ });
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
+
221
+ server.listen(PORT, () => console.log(`__APP_NAME__ listening on http://localhost:${PORT}`));