Skip to content

Commit e02a2ab

Browse files
committed
Remove the kill feature; the product is remote config with update walls and maintenance
1 parent f57482e commit e02a2ab

11 files changed

Lines changed: 407 additions & 320 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Removed the kill switch, following its removal from the protocol. Evaluation
6+
order is now maintenance → force → soft → none. Old payloads that still carry
7+
a `kill` block parse fine; the block is ignored.
8+
39
## 0.1.0
410

511
First release.

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @ripstop/react-native
22

3-
**Force update, kill switch, maintenance mode and remote config for React Native.**
3+
**Remote config, update walls and maintenance mode for React Native.**
44
Decided at the edge, verified on device.
55

66
[![npm](https://img.shields.io/npm/v/@ripstop/react-native.svg)](https://www.npmjs.com/package/@ripstop/react-native)
@@ -62,9 +62,9 @@ restarts.
6262
| No network | Uses the last **signed** payload |
6363
| No network, no cache | `none` — your app runs, unrestricted |
6464
| Server returns 500, or times out | Cache, then normal |
65-
| Signature doesn't verify | Discarded. A forged payload can never kill your app |
65+
| Signature doesn't verify | Discarded. A forged payload can never wall your app |
6666
| Cache tampered with | Re-verified on read, so it grants nothing |
67-
| Kill switch on, then network lost | The kill **stays**, until a fresh signed payload clears it |
67+
| Maintenance on, then network lost | The wall **stays**, until a fresh signed payload clears it |
6868

6969
## About `crypto.subtle`
7070

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@ripstop/react-native",
33
"version": "0.1.0",
4-
"description": "Force update, kill switch, maintenance mode and remote config for React Native — signed at the edge, verified on device.",
4+
"description": "Remote config, update walls and maintenance mode for React Native — signed at the edge, verified on device.",
55
"license": "MIT",
66
"repository": { "type": "git", "url": "git+https://github.com/ripstop-dev/ripstop-react-native.git" },
77
"homepage": "https://ripstop.dev/docs/react-native",
@@ -37,5 +37,5 @@
3737
"typescript-eslint": "^8.65.0",
3838
"vitest": "^4.1.10"
3939
},
40-
"keywords": ["react-native", "force-update", "kill-switch", "remote-config", "maintenance-mode"]
40+
"keywords": ["react-native", "force-update", "remote-config", "maintenance-mode"]
4141
}

src/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ export class Ripstop {
176176
/**
177177
* Adopts a payload only if it still verifies. Cached payloads get the same
178178
* scrutiny as fresh ones — localStorage is editable from the console, so
179-
* trusting it would make the kill switch a suggestion.
179+
* trusting it would make every wall a suggestion.
180180
*/
181181
private async adopt(
182182
body: string,

src/evaluate.ts

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ export interface EvaluateContext {
2828
}
2929

3030
export type Decision =
31-
| { type: 'kill'; message: string }
3231
| {
3332
type: 'maintenance';
3433
title: string;
@@ -52,26 +51,7 @@ export function evaluate(config: RipstopConfig, ctx: EvaluateContext): Decision
5251
const locale = ctx.locale ?? FALLBACK_LOCALE;
5352
const version = parseVersion(ctx.appVersion);
5453

55-
// 1. Kill
56-
const kill = config.kill;
57-
if (kill.active) {
58-
const platformMatch = kill.platforms.length === 0 || kill.platforms.includes(ctx.platform);
59-
let rangeMatch = kill.version_ranges.length === 0;
60-
if (!rangeMatch && version !== null) {
61-
rangeMatch = kill.version_ranges.some((r) => {
62-
const from = r.from === undefined ? null : parseVersion(r.from);
63-
const to = r.to === undefined ? null : parseVersion(r.to);
64-
if (from !== null && compareParsed(version, from) < 0) return false;
65-
if (to !== null && compareParsed(version, to) > 0) return false;
66-
return true;
67-
});
68-
}
69-
if (platformMatch && rangeMatch) {
70-
return { type: 'kill', message: resolveMessage(config, locale, kill.message_key) };
71-
}
72-
}
73-
74-
// 2. Maintenance — `active` is server-evaluated at fetch time; starts_at/ends_at are display-only.
54+
// 1. Maintenance — `active` is server-evaluated at fetch time; starts_at/ends_at are display-only.
7555
const maintenance = config.maintenance;
7656
if (maintenance.active) {
7757
return {
@@ -85,7 +65,7 @@ export function evaluate(config: RipstopConfig, ctx: EvaluateContext): Decision
8565
};
8666
}
8767

88-
// 3–4. Force / soft — need a platform entry and a parseable version; otherwise fail open.
68+
// 2–3. Force / soft — need a platform entry and a parseable version; otherwise fail open.
8969
const entry = config.update[ctx.platform];
9070
if (entry === undefined || version === null) return { type: 'none' };
9171

@@ -124,8 +104,8 @@ export function evaluate(config: RipstopConfig, ctx: EvaluateContext): Decision
124104
/**
125105
* Fail-open state machine (README §9): which config may drive a decision after
126106
* a fetch attempt. Any failure falls back to the last cached *signed* config;
127-
* with no cache, behave as `none`. Kill stickiness follows: a cached kill stays
128-
* in force until a fresh, signed config clears it.
107+
* with no cache, behave as `none`. Stickiness follows: a cached restriction
108+
* stays in force until a fresh, signed config clears it.
129109
*/
130110
export type FetchOutcome = 'ok' | 'http_error' | 'timeout' | 'invalid_signature';
131111
export type ConfigSource = 'fresh' | 'cached' | 'none';

src/index.tsx

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export function RipstopProvider({
8484

8585
// Options are read once, on mount: an SDK that re-initialises because a
8686
// parent re-rendered would refetch on every keystroke somewhere up the tree.
87-
const initial = useMemo(() => options, []); // eslint-disable-line react-hooks/exhaustive-deps
87+
const initial = useMemo(() => options, []);
8888

8989
useEffect(() => {
9090
let cancelled = false;
@@ -161,14 +161,6 @@ function Walls({
161161
if (loading) return <>{children}</>;
162162

163163
switch (decision.type) {
164-
case 'kill':
165-
return (
166-
<Wall
167-
theme={theme}
168-
title={decision.message || 'This version is no longer available'}
169-
body=""
170-
/>
171-
);
172164
case 'maintenance':
173165
return (
174166
<Wall

src/storage.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
* The payload is stored *with* its signature and re-verified on read, so a
55
* cached config carries exactly as much authority as a fresh one — and no more.
66
* On the web this matters more than on mobile: localStorage is two keystrokes
7-
* away in devtools, so a cache that were trusted would make the kill switch a
8-
* polite request.
7+
* away in devtools, so a cache that were trusted would make a maintenance wall
8+
* a polite request.
99
*/
1010

1111
export interface CachedConfig {

src/types.ts

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,6 @@ export type JsonValue =
1919
| JsonValue[]
2020
| { [key: string]: JsonValue };
2121

22-
export interface VersionRange {
23-
/** Inclusive; absent means unbounded. */
24-
from?: string;
25-
to?: string;
26-
}
27-
28-
export interface KillSwitch {
29-
active: boolean;
30-
/** Empty means every platform. */
31-
platforms: Platform[];
32-
/** Empty means every version. */
33-
version_ranges: VersionRange[];
34-
message_key: string;
35-
}
36-
3722
export interface Maintenance {
3823
/** Server-evaluated at fetch time. The browser never checks the schedule. */
3924
active: boolean;
@@ -63,7 +48,6 @@ export interface RipstopConfig {
6348
env: string;
6449
published_at: string;
6550
key_id: string;
66-
kill: KillSwitch;
6751
maintenance: Maintenance;
6852
update: Partial<Record<Platform, UpdateEntry>>;
6953
values: Record<string, JsonValue>;
@@ -85,7 +69,8 @@ const int = (v: unknown, fallback: number): number =>
8569
* Forgiving in exactly one direction: unknown fields are ignored, so a newer
8670
* control plane can add one without breaking apps already in the field. A
8771
* field we do recognise but cannot read makes the whole payload null, and a
88-
* null payload never drives a decision.
72+
* null payload never drives a decision. The retired `kill` block rides the
73+
* same rule: old cached payloads still carry it, and it is simply not read.
8974
*/
9075
export function parseConfig(input: unknown): RipstopConfig | null {
9176
if (!isRecord(input)) return null;
@@ -122,7 +107,6 @@ export function parseConfig(input: unknown): RipstopConfig | null {
122107
messages[locale] = out;
123108
}
124109

125-
const kill = isRecord(input.kill) ? input.kill : {};
126110
const maintenance = isRecord(input.maintenance) ? input.maintenance : {};
127111

128112
return {
@@ -131,19 +115,6 @@ export function parseConfig(input: unknown): RipstopConfig | null {
131115
env: str(input.env, 'production'),
132116
published_at: str(input.published_at),
133117
key_id: str(input.key_id),
134-
kill: {
135-
active: bool(kill.active),
136-
platforms: Array.isArray(kill.platforms)
137-
? (kill.platforms.filter((p) => typeof p === 'string') as Platform[])
138-
: [],
139-
version_ranges: Array.isArray(kill.version_ranges)
140-
? kill.version_ranges.filter(isRecord).map((r) => ({
141-
...(typeof r.from === 'string' ? { from: r.from } : {}),
142-
...(typeof r.to === 'string' ? { to: r.to } : {}),
143-
}))
144-
: [],
145-
message_key: str(kill.message_key, 'kill_default'),
146-
},
147118
maintenance: {
148119
active: bool(maintenance.active),
149120
starts_at: typeof maintenance.starts_at === 'string' ? maintenance.starts_at : null,

src/verify.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* anything is parsed. `JSON.parse` then `JSON.stringify` would produce
1111
* different bytes for the same document and reject payloads that were genuine.
1212
*/
13-
import { etc, hashes, verifyAsync } from '@noble/ed25519';
13+
import { hashes, verifyAsync } from '@noble/ed25519';
1414
import { sha512 } from '@noble/hashes/sha2';
1515

1616
// Wired once, at module load, before any verification can run.

test/client.test.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,15 @@ beforeAll(async () => {
2626
publicKeyB64 = bytesToBase64(await getPublicKeyAsync(privateKey));
2727
});
2828

29-
function config(options: { kill?: boolean; min?: string; target?: string } = {}) {
29+
function config(options: { maintenance?: boolean; min?: string; target?: string } = {}) {
3030
return {
3131
v: 1,
3232
app: 'app_test',
3333
env: 'production',
3434
published_at: '2026-01-01T00:00:00Z',
3535
key_id: KEY_ID,
36-
kill: {
37-
active: options.kill ?? false,
38-
platforms: [],
39-
version_ranges: [],
40-
message_key: 'kill_default',
41-
},
4236
maintenance: {
43-
active: false,
37+
active: options.maintenance ?? false,
4438
starts_at: null,
4539
ends_at: null,
4640
message_key: 'maint_default',
@@ -61,7 +55,8 @@ function config(options: { kill?: boolean; min?: string; target?: string } = {})
6155
force_body: 'Reload to continue.',
6256
soft_title: 'Update available',
6357
soft_body: 'A new version is ready.',
64-
kill_default: 'App unavailable',
58+
maint_title: 'Back shortly',
59+
maint_default: 'Down for maintenance.',
6560
},
6661
},
6762
};
@@ -118,7 +113,7 @@ describe('the client', () => {
118113
});
119114

120115
it('refuses a forged signature and lets the app run', async () => {
121-
const { impl } = server(config({ kill: true }), { corrupt: true });
116+
const { impl } = server(config({ maintenance: true }), { corrupt: true });
122117
const gate = await boot(impl);
123118

124119
expect((await gate.check()).type).toBe('none');
@@ -133,23 +128,23 @@ describe('the client', () => {
133128

134129
it('falls back to cache when the network fails', async () => {
135130
const storage = new MemoryStorage();
136-
const good = server(config({ kill: true }));
131+
const good = server(config({ maintenance: true }));
137132
const first = await boot(good.impl, { storage });
138-
expect((await first.check()).type).toBe('kill');
133+
expect((await first.check()).type).toBe('maintenance');
139134

140135
const broken = server(config(), { status: 500 });
141136
const second = await boot(broken.impl, { storage });
142137

143-
expect((await second.check()).type).toBe('kill');
138+
expect((await second.check()).type).toBe('maintenance');
144139
expect(second.source).toBe('cached');
145140
});
146141

147142
it('refuses a tampered cache', async () => {
148143
const storage = new MemoryStorage();
149-
const good = server(config({ kill: true }));
144+
const good = server(config({ maintenance: true }));
150145
await boot(good.impl, { storage });
151146

152-
// Someone opens devtools and edits localStorage to lift the kill.
147+
// Someone opens devtools and edits localStorage to lift the wall.
153148
const raw = JSON.parse(storage.read(`ripstop.config.${KEY}`)!) as { body: string };
154149
raw.body = JSON.stringify(config());
155150
storage.write(`ripstop.config.${KEY}`, JSON.stringify(raw));

0 commit comments

Comments
 (0)