@stardeck-customer-apps/controls 0.0.0-stage → 0.1.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/SKILL.md +129 -0
- package/dist/index.cjs +105 -0
- package/dist/index.d.cts +103 -0
- package/dist/index.d.ts +103 -0
- package/dist/index.js +73 -0
- package/dist/server/index.cjs +420 -0
- package/dist/server/index.d.cts +138 -0
- package/dist/server/index.d.ts +138 -0
- package/dist/server/index.js +387 -0
- package/package.json +64 -4
- package/README.md +0 -3
package/SKILL.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Controls: what Stardeck decides for your app
|
|
2
|
+
|
|
3
|
+
**Package**: `@stardeck-customer-apps/controls`
|
|
4
|
+
|
|
5
|
+
Controls are values Stardeck owns and your app reads while it runs: feature
|
|
6
|
+
flags, what the plan allows, and which payment provider and methods the app
|
|
7
|
+
uses. Your app asks Stardeck about once a minute and keeps the answer in memory.
|
|
8
|
+
A change on Stardeck reaches the app within about a minute, with no deploy.
|
|
9
|
+
|
|
10
|
+
Never invent controls or flags of your own. The list is fixed by Stardeck and
|
|
11
|
+
shipped in this package. If a behavior needs a switch that is not on the list,
|
|
12
|
+
store it as an app setting instead.
|
|
13
|
+
|
|
14
|
+
## Reading a control
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { getControl, getControls } from "@stardeck-customer-apps/controls/server";
|
|
18
|
+
|
|
19
|
+
const multiEntity = await getControl("plan.multiEntity");
|
|
20
|
+
if (multiEntity.ok && multiEntity.value) {
|
|
21
|
+
// allow a second company
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`getControl(key)` never throws. It returns `{ ok: true, value }` or
|
|
26
|
+
`{ ok: false, reason }`. `ok: false` means "unknown right now" (Stardeck could
|
|
27
|
+
not be reached on a cold start, or the value was not provided). It does **not**
|
|
28
|
+
mean the feature is off, and it does not mean the limit is unlimited. Handle it:
|
|
29
|
+
|
|
30
|
+
- Refuse the new thing (a new location, a new company, a new checkout) with a
|
|
31
|
+
retry message.
|
|
32
|
+
- Keep everything that already exists working (sales, bookings, reads).
|
|
33
|
+
- Do not cache `ok: false` yourself.
|
|
34
|
+
|
|
35
|
+
After the first success the app keeps serving the last good value if Stardeck is
|
|
36
|
+
briefly unreachable, so `ok: false` is rare in practice.
|
|
37
|
+
|
|
38
|
+
`getControls()` returns every control at once. The cache lives for the life of
|
|
39
|
+
the server process and is shared by every request. `refreshControls()` asks the
|
|
40
|
+
platform now, ignoring the cache; use it when the platform has just told you a
|
|
41
|
+
value moved (a payment refused with `PROVIDER_CHANGED`). If that request fails,
|
|
42
|
+
the last values stay.
|
|
43
|
+
|
|
44
|
+
## The controls
|
|
45
|
+
|
|
46
|
+
| Key | Value | App may write |
|
|
47
|
+
| ------------------------ | ----------------------------------------------------------------------------------------------- | ------------- |
|
|
48
|
+
| `flag.paymentsSetupStep` | `boolean` | no |
|
|
49
|
+
| `plan.locations` | `{ included: number \| null, max: number \| null, extraMonthly: { amount, currency } \| null }` | no |
|
|
50
|
+
| `plan.multiEntity` | `boolean` | no |
|
|
51
|
+
| `payments.provider` | `"beam" \| "stripe" \| null` | yes |
|
|
52
|
+
| `payments.methods` | `{ card: boolean, promptPay: boolean }` | yes |
|
|
53
|
+
|
|
54
|
+
- `plan.locations.included` is what the plan covers. `max` is a hard cap (the
|
|
55
|
+
entry plan has 1); `null` means no cap, and each location past `included` is
|
|
56
|
+
billed at `extraMonthly`. `included: null` means the number was agreed per deal.
|
|
57
|
+
- Keys are never removed in later versions, so code written against today's list
|
|
58
|
+
keeps working.
|
|
59
|
+
|
|
60
|
+
Types and the list itself are in the root export:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { CONTROLS, parseControls, type ControlKey } from "@stardeck-customer-apps/controls";
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Changing provider or methods
|
|
67
|
+
|
|
68
|
+
Only `payments.provider` and `payments.methods` can be changed from the app, and
|
|
69
|
+
only on behalf of a signed-in person. Pass that person's session token. Stardeck
|
|
70
|
+
checks that this person may change the app's settings; the app's own roles are
|
|
71
|
+
never enough.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { cookies } from "next/headers";
|
|
75
|
+
import { setControl } from "@stardeck-customer-apps/controls/server";
|
|
76
|
+
|
|
77
|
+
// The token is the project-auth session cookie "__stardeck:sess", the same one
|
|
78
|
+
// AuthManagementClient sends as X-Stardeck-User-Token.
|
|
79
|
+
const userToken = (await cookies()).get("__stardeck:sess")?.value;
|
|
80
|
+
if (!userToken) return unauthorized();
|
|
81
|
+
|
|
82
|
+
const result = await setControl("payments.provider", "stripe", { userToken });
|
|
83
|
+
if (!result.ok) {
|
|
84
|
+
// result.error.code: NOT_WRITABLE | INVALID_VALUE | UNAUTHENTICATED | FORBIDDEN |
|
|
85
|
+
// WRITE_NOT_ALLOWED_HERE | PROVIDER_NOT_READY | UNAVAILABLE
|
|
86
|
+
// PROVIDER_NOT_READY carries result.error.connectUrl: send the person there to
|
|
87
|
+
// finish connecting, and they come back with the provider switched on.
|
|
88
|
+
// WRITE_NOT_ALLOWED_HERE: previews and stagings change this on Stardeck instead.
|
|
89
|
+
return refuse(result.error);
|
|
90
|
+
}
|
|
91
|
+
// result.controls is the fresh set; the app's cache already has it.
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Show `error.message` to the person. Do not retry `FORBIDDEN` or
|
|
95
|
+
`WRITE_NOT_ALLOWED_HERE`. A successful write refreshes this server immediately;
|
|
96
|
+
other server instances catch up within a minute. Payment creation is checked
|
|
97
|
+
live by Stardeck, so a stale instance cannot charge on the old provider.
|
|
98
|
+
|
|
99
|
+
## Reporting usage
|
|
100
|
+
|
|
101
|
+
Stardeck bills extra locations from what the app reports. Register a provider
|
|
102
|
+
once at startup; it runs on every sync (about once a minute per server):
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { setUsageProvider } from "@stardeck-customer-apps/controls/server";
|
|
106
|
+
|
|
107
|
+
setUsageProvider(async () => ({ locations: await countActiveLocations() }));
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Report non-negative integers only, counted from the app's own rows. Keep the
|
|
111
|
+
count to one quick query: if the provider throws or takes longer than a second,
|
|
112
|
+
the error is logged and that sync goes without usage.
|
|
113
|
+
|
|
114
|
+
## Configuration
|
|
115
|
+
|
|
116
|
+
The client reads `CONTROL_PLANE_URL`,
|
|
117
|
+
`ORGANIZATION_ID`, `PROJECT_ID`, `DEPLOYMENT_ID` and `DEPLOYMENT_SECRET`, the same
|
|
118
|
+
deployment env the other Stardeck SDKs use. If any is missing, every control
|
|
119
|
+
reads `{ ok: false, reason: "not configured" }` and one warning is logged. For
|
|
120
|
+
custom wiring use `createControlsClient({ controlPlaneUrl, organizationId, projectId, deploymentId, deploymentSecret })`.
|
|
121
|
+
|
|
122
|
+
## Testing
|
|
123
|
+
|
|
124
|
+
`@stardeck-customer-apps/testing` simulates the sync and write routes:
|
|
125
|
+
`createTestApp({ controls: { "plan.multiEntity": false } })`, then
|
|
126
|
+
`app.controls.set(key, value)`, `app.controls.setUnavailable(key)`,
|
|
127
|
+
`app.controls.lastUsage()`, `app.controls.writes()` and
|
|
128
|
+
`app.controls.refuseNextWrite({ code, message, connectUrl })`. A `set` is visible
|
|
129
|
+
to the next `getControl` call immediately. Always cover the `ok: false` path.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
CONTROLS: () => CONTROLS,
|
|
24
|
+
CONTROL_KEYS: () => CONTROL_KEYS,
|
|
25
|
+
isControlKey: () => isControlKey,
|
|
26
|
+
isWritableControl: () => isWritableControl,
|
|
27
|
+
parseControls: () => parseControls,
|
|
28
|
+
unavailableControls: () => unavailableControls
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(index_exports);
|
|
31
|
+
|
|
32
|
+
// src/controls.ts
|
|
33
|
+
var import_zod = require("zod");
|
|
34
|
+
var CONTROLS = {
|
|
35
|
+
"flag.paymentsSetupStep": { schema: import_zod.z.boolean(), appWrite: false },
|
|
36
|
+
"plan.locations": {
|
|
37
|
+
schema: import_zod.z.object({
|
|
38
|
+
/** Locations the plan includes. null = agreed per deal. */
|
|
39
|
+
included: import_zod.z.number().int().positive().nullable(),
|
|
40
|
+
/** Hard cap. Platform plan: 1; others: null (no cap, extras are billed). */
|
|
41
|
+
max: import_zod.z.number().int().positive().nullable(),
|
|
42
|
+
/** Monthly price of each location past `included`. null = not offered. */
|
|
43
|
+
extraMonthly: import_zod.z.object({
|
|
44
|
+
amount: import_zod.z.number().nonnegative(),
|
|
45
|
+
currency: import_zod.z.enum(["THB", "USD"])
|
|
46
|
+
}).nullable()
|
|
47
|
+
}),
|
|
48
|
+
appWrite: false
|
|
49
|
+
},
|
|
50
|
+
"plan.multiEntity": { schema: import_zod.z.boolean(), appWrite: false },
|
|
51
|
+
"payments.provider": { schema: import_zod.z.enum(["beam", "stripe"]).nullable(), appWrite: true },
|
|
52
|
+
"payments.methods": {
|
|
53
|
+
schema: import_zod.z.object({ card: import_zod.z.boolean(), promptPay: import_zod.z.boolean() }),
|
|
54
|
+
appWrite: true
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
var CONTROL_KEYS = Object.keys(CONTROLS);
|
|
58
|
+
function isControlKey(key) {
|
|
59
|
+
return Object.prototype.hasOwnProperty.call(CONTROLS, key);
|
|
60
|
+
}
|
|
61
|
+
function isWritableControl(key) {
|
|
62
|
+
return isControlKey(key) && CONTROLS[key].appWrite === true;
|
|
63
|
+
}
|
|
64
|
+
function parseOne(entry, schema) {
|
|
65
|
+
if (typeof entry !== "object" || entry === null) {
|
|
66
|
+
return { ok: false, reason: "not provided by the platform" };
|
|
67
|
+
}
|
|
68
|
+
const record = entry;
|
|
69
|
+
if (record.ok === false) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
reason: typeof record.reason === "string" ? record.reason : "unavailable"
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (record.ok !== true) {
|
|
76
|
+
return { ok: false, reason: "malformed control" };
|
|
77
|
+
}
|
|
78
|
+
const parsed = schema.safeParse(record.value);
|
|
79
|
+
if (!parsed.success) {
|
|
80
|
+
return { ok: false, reason: `invalid value: ${parsed.error.issues[0]?.message ?? "unknown"}` };
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, value: parsed.data };
|
|
83
|
+
}
|
|
84
|
+
function parseControls(raw) {
|
|
85
|
+
const source = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {};
|
|
86
|
+
const result = {};
|
|
87
|
+
for (const key of CONTROL_KEYS) {
|
|
88
|
+
result[key] = parseOne(source[key], CONTROLS[key].schema);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
function unavailableControls(reason) {
|
|
93
|
+
const result = {};
|
|
94
|
+
for (const key of CONTROL_KEYS) result[key] = { ok: false, reason };
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
98
|
+
0 && (module.exports = {
|
|
99
|
+
CONTROLS,
|
|
100
|
+
CONTROL_KEYS,
|
|
101
|
+
isControlKey,
|
|
102
|
+
isWritableControl,
|
|
103
|
+
parseControls,
|
|
104
|
+
unavailableControls
|
|
105
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The controls list: every value the platform owns and an app reads.
|
|
5
|
+
*
|
|
6
|
+
* Isomorphic and data only. The platform imports this same list, so the
|
|
7
|
+
* platform and the app cannot disagree on a control's shape.
|
|
8
|
+
*
|
|
9
|
+
* Keys are never removed in future versions. An older app keeps asking for the
|
|
10
|
+
* keys it was built with, so a key can be added, never renamed or dropped.
|
|
11
|
+
* `appWrite: true` marks the few controls the app may change on a person's
|
|
12
|
+
* behalf (the platform still checks that person's permissions).
|
|
13
|
+
*/
|
|
14
|
+
declare const CONTROLS: {
|
|
15
|
+
readonly "flag.paymentsSetupStep": {
|
|
16
|
+
readonly schema: z.ZodBoolean;
|
|
17
|
+
readonly appWrite: false;
|
|
18
|
+
};
|
|
19
|
+
readonly "plan.locations": {
|
|
20
|
+
readonly schema: z.ZodObject<{
|
|
21
|
+
included: z.ZodNullable<z.ZodNumber>;
|
|
22
|
+
max: z.ZodNullable<z.ZodNumber>;
|
|
23
|
+
extraMonthly: z.ZodNullable<z.ZodObject<{
|
|
24
|
+
amount: z.ZodNumber;
|
|
25
|
+
currency: z.ZodEnum<{
|
|
26
|
+
THB: "THB";
|
|
27
|
+
USD: "USD";
|
|
28
|
+
}>;
|
|
29
|
+
}, z.core.$strip>>;
|
|
30
|
+
}, z.core.$strip>;
|
|
31
|
+
readonly appWrite: false;
|
|
32
|
+
};
|
|
33
|
+
readonly "plan.multiEntity": {
|
|
34
|
+
readonly schema: z.ZodBoolean;
|
|
35
|
+
readonly appWrite: false;
|
|
36
|
+
};
|
|
37
|
+
readonly "payments.provider": {
|
|
38
|
+
readonly schema: z.ZodNullable<z.ZodEnum<{
|
|
39
|
+
beam: "beam";
|
|
40
|
+
stripe: "stripe";
|
|
41
|
+
}>>;
|
|
42
|
+
readonly appWrite: true;
|
|
43
|
+
};
|
|
44
|
+
readonly "payments.methods": {
|
|
45
|
+
readonly schema: z.ZodObject<{
|
|
46
|
+
card: z.ZodBoolean;
|
|
47
|
+
promptPay: z.ZodBoolean;
|
|
48
|
+
}, z.core.$strip>;
|
|
49
|
+
readonly appWrite: true;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
type ControlKey = keyof typeof CONTROLS;
|
|
53
|
+
type ControlValue<K extends ControlKey> = z.infer<(typeof CONTROLS)[K]["schema"]>;
|
|
54
|
+
/** One control as the app sees it. `ok: false` means "unknown right now". */
|
|
55
|
+
type ControlResult<K extends ControlKey> = {
|
|
56
|
+
ok: true;
|
|
57
|
+
value: ControlValue<K>;
|
|
58
|
+
} | {
|
|
59
|
+
ok: false;
|
|
60
|
+
reason: string;
|
|
61
|
+
};
|
|
62
|
+
type Controls = {
|
|
63
|
+
[K in ControlKey]: ControlResult<K>;
|
|
64
|
+
};
|
|
65
|
+
type WritableControlKey = {
|
|
66
|
+
[K in ControlKey]: (typeof CONTROLS)[K]["appWrite"] extends true ? K : never;
|
|
67
|
+
}[ControlKey];
|
|
68
|
+
/** What the app reports to the platform on every sync. Non-negative integers. */
|
|
69
|
+
type ControlsUsage = {
|
|
70
|
+
locations?: number;
|
|
71
|
+
};
|
|
72
|
+
/** POST /api/deployments/{deploymentId}/controls body. */
|
|
73
|
+
type ControlsSyncRequest = {
|
|
74
|
+
usage?: ControlsUsage;
|
|
75
|
+
};
|
|
76
|
+
/** POST response. Each value is `{ ok: true, value }` or `{ ok: false, reason }`. */
|
|
77
|
+
type ControlsSyncResponse = {
|
|
78
|
+
controls: Record<string, unknown>;
|
|
79
|
+
};
|
|
80
|
+
/** PUT /api/deployments/{deploymentId}/controls/{key} body. The person's session goes in `X-Stardeck-User-Token`. */
|
|
81
|
+
type ControlWriteRequest = {
|
|
82
|
+
value: unknown;
|
|
83
|
+
};
|
|
84
|
+
type ControlWriteErrorCode = "NOT_WRITABLE" | "INVALID_VALUE" | "UNAUTHENTICATED" | "FORBIDDEN" | "WRITE_NOT_ALLOWED_HERE" | "PROVIDER_NOT_READY" | "UNAVAILABLE";
|
|
85
|
+
type ControlWriteError = {
|
|
86
|
+
code: ControlWriteErrorCode;
|
|
87
|
+
message: string;
|
|
88
|
+
/** Where the person finishes connecting a provider, when that is the blocker. */
|
|
89
|
+
connectUrl?: string;
|
|
90
|
+
};
|
|
91
|
+
declare const CONTROL_KEYS: ControlKey[];
|
|
92
|
+
declare function isControlKey(key: string): key is ControlKey;
|
|
93
|
+
declare function isWritableControl(key: string): key is WritableControlKey;
|
|
94
|
+
/**
|
|
95
|
+
* Validates the platform's `controls` map. Each key is validated on its own: a
|
|
96
|
+
* missing or invalid key becomes `{ ok: false, reason }` and never poisons the
|
|
97
|
+
* others. Keys this version does not know are ignored.
|
|
98
|
+
*/
|
|
99
|
+
declare function parseControls(raw: unknown): Controls;
|
|
100
|
+
/** Every control unavailable for one reason (cold start with the platform down, or not configured). */
|
|
101
|
+
declare function unavailableControls(reason: string): Controls;
|
|
102
|
+
|
|
103
|
+
export { CONTROLS, CONTROL_KEYS, type ControlKey, type ControlResult, type ControlValue, type ControlWriteError, type ControlWriteErrorCode, type ControlWriteRequest, type Controls, type ControlsSyncRequest, type ControlsSyncResponse, type ControlsUsage, type WritableControlKey, isControlKey, isWritableControl, parseControls, unavailableControls };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The controls list: every value the platform owns and an app reads.
|
|
5
|
+
*
|
|
6
|
+
* Isomorphic and data only. The platform imports this same list, so the
|
|
7
|
+
* platform and the app cannot disagree on a control's shape.
|
|
8
|
+
*
|
|
9
|
+
* Keys are never removed in future versions. An older app keeps asking for the
|
|
10
|
+
* keys it was built with, so a key can be added, never renamed or dropped.
|
|
11
|
+
* `appWrite: true` marks the few controls the app may change on a person's
|
|
12
|
+
* behalf (the platform still checks that person's permissions).
|
|
13
|
+
*/
|
|
14
|
+
declare const CONTROLS: {
|
|
15
|
+
readonly "flag.paymentsSetupStep": {
|
|
16
|
+
readonly schema: z.ZodBoolean;
|
|
17
|
+
readonly appWrite: false;
|
|
18
|
+
};
|
|
19
|
+
readonly "plan.locations": {
|
|
20
|
+
readonly schema: z.ZodObject<{
|
|
21
|
+
included: z.ZodNullable<z.ZodNumber>;
|
|
22
|
+
max: z.ZodNullable<z.ZodNumber>;
|
|
23
|
+
extraMonthly: z.ZodNullable<z.ZodObject<{
|
|
24
|
+
amount: z.ZodNumber;
|
|
25
|
+
currency: z.ZodEnum<{
|
|
26
|
+
THB: "THB";
|
|
27
|
+
USD: "USD";
|
|
28
|
+
}>;
|
|
29
|
+
}, z.core.$strip>>;
|
|
30
|
+
}, z.core.$strip>;
|
|
31
|
+
readonly appWrite: false;
|
|
32
|
+
};
|
|
33
|
+
readonly "plan.multiEntity": {
|
|
34
|
+
readonly schema: z.ZodBoolean;
|
|
35
|
+
readonly appWrite: false;
|
|
36
|
+
};
|
|
37
|
+
readonly "payments.provider": {
|
|
38
|
+
readonly schema: z.ZodNullable<z.ZodEnum<{
|
|
39
|
+
beam: "beam";
|
|
40
|
+
stripe: "stripe";
|
|
41
|
+
}>>;
|
|
42
|
+
readonly appWrite: true;
|
|
43
|
+
};
|
|
44
|
+
readonly "payments.methods": {
|
|
45
|
+
readonly schema: z.ZodObject<{
|
|
46
|
+
card: z.ZodBoolean;
|
|
47
|
+
promptPay: z.ZodBoolean;
|
|
48
|
+
}, z.core.$strip>;
|
|
49
|
+
readonly appWrite: true;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
type ControlKey = keyof typeof CONTROLS;
|
|
53
|
+
type ControlValue<K extends ControlKey> = z.infer<(typeof CONTROLS)[K]["schema"]>;
|
|
54
|
+
/** One control as the app sees it. `ok: false` means "unknown right now". */
|
|
55
|
+
type ControlResult<K extends ControlKey> = {
|
|
56
|
+
ok: true;
|
|
57
|
+
value: ControlValue<K>;
|
|
58
|
+
} | {
|
|
59
|
+
ok: false;
|
|
60
|
+
reason: string;
|
|
61
|
+
};
|
|
62
|
+
type Controls = {
|
|
63
|
+
[K in ControlKey]: ControlResult<K>;
|
|
64
|
+
};
|
|
65
|
+
type WritableControlKey = {
|
|
66
|
+
[K in ControlKey]: (typeof CONTROLS)[K]["appWrite"] extends true ? K : never;
|
|
67
|
+
}[ControlKey];
|
|
68
|
+
/** What the app reports to the platform on every sync. Non-negative integers. */
|
|
69
|
+
type ControlsUsage = {
|
|
70
|
+
locations?: number;
|
|
71
|
+
};
|
|
72
|
+
/** POST /api/deployments/{deploymentId}/controls body. */
|
|
73
|
+
type ControlsSyncRequest = {
|
|
74
|
+
usage?: ControlsUsage;
|
|
75
|
+
};
|
|
76
|
+
/** POST response. Each value is `{ ok: true, value }` or `{ ok: false, reason }`. */
|
|
77
|
+
type ControlsSyncResponse = {
|
|
78
|
+
controls: Record<string, unknown>;
|
|
79
|
+
};
|
|
80
|
+
/** PUT /api/deployments/{deploymentId}/controls/{key} body. The person's session goes in `X-Stardeck-User-Token`. */
|
|
81
|
+
type ControlWriteRequest = {
|
|
82
|
+
value: unknown;
|
|
83
|
+
};
|
|
84
|
+
type ControlWriteErrorCode = "NOT_WRITABLE" | "INVALID_VALUE" | "UNAUTHENTICATED" | "FORBIDDEN" | "WRITE_NOT_ALLOWED_HERE" | "PROVIDER_NOT_READY" | "UNAVAILABLE";
|
|
85
|
+
type ControlWriteError = {
|
|
86
|
+
code: ControlWriteErrorCode;
|
|
87
|
+
message: string;
|
|
88
|
+
/** Where the person finishes connecting a provider, when that is the blocker. */
|
|
89
|
+
connectUrl?: string;
|
|
90
|
+
};
|
|
91
|
+
declare const CONTROL_KEYS: ControlKey[];
|
|
92
|
+
declare function isControlKey(key: string): key is ControlKey;
|
|
93
|
+
declare function isWritableControl(key: string): key is WritableControlKey;
|
|
94
|
+
/**
|
|
95
|
+
* Validates the platform's `controls` map. Each key is validated on its own: a
|
|
96
|
+
* missing or invalid key becomes `{ ok: false, reason }` and never poisons the
|
|
97
|
+
* others. Keys this version does not know are ignored.
|
|
98
|
+
*/
|
|
99
|
+
declare function parseControls(raw: unknown): Controls;
|
|
100
|
+
/** Every control unavailable for one reason (cold start with the platform down, or not configured). */
|
|
101
|
+
declare function unavailableControls(reason: string): Controls;
|
|
102
|
+
|
|
103
|
+
export { CONTROLS, CONTROL_KEYS, type ControlKey, type ControlResult, type ControlValue, type ControlWriteError, type ControlWriteErrorCode, type ControlWriteRequest, type Controls, type ControlsSyncRequest, type ControlsSyncResponse, type ControlsUsage, type WritableControlKey, isControlKey, isWritableControl, parseControls, unavailableControls };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// src/controls.ts
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
var CONTROLS = {
|
|
4
|
+
"flag.paymentsSetupStep": { schema: z.boolean(), appWrite: false },
|
|
5
|
+
"plan.locations": {
|
|
6
|
+
schema: z.object({
|
|
7
|
+
/** Locations the plan includes. null = agreed per deal. */
|
|
8
|
+
included: z.number().int().positive().nullable(),
|
|
9
|
+
/** Hard cap. Platform plan: 1; others: null (no cap, extras are billed). */
|
|
10
|
+
max: z.number().int().positive().nullable(),
|
|
11
|
+
/** Monthly price of each location past `included`. null = not offered. */
|
|
12
|
+
extraMonthly: z.object({
|
|
13
|
+
amount: z.number().nonnegative(),
|
|
14
|
+
currency: z.enum(["THB", "USD"])
|
|
15
|
+
}).nullable()
|
|
16
|
+
}),
|
|
17
|
+
appWrite: false
|
|
18
|
+
},
|
|
19
|
+
"plan.multiEntity": { schema: z.boolean(), appWrite: false },
|
|
20
|
+
"payments.provider": { schema: z.enum(["beam", "stripe"]).nullable(), appWrite: true },
|
|
21
|
+
"payments.methods": {
|
|
22
|
+
schema: z.object({ card: z.boolean(), promptPay: z.boolean() }),
|
|
23
|
+
appWrite: true
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
var CONTROL_KEYS = Object.keys(CONTROLS);
|
|
27
|
+
function isControlKey(key) {
|
|
28
|
+
return Object.prototype.hasOwnProperty.call(CONTROLS, key);
|
|
29
|
+
}
|
|
30
|
+
function isWritableControl(key) {
|
|
31
|
+
return isControlKey(key) && CONTROLS[key].appWrite === true;
|
|
32
|
+
}
|
|
33
|
+
function parseOne(entry, schema) {
|
|
34
|
+
if (typeof entry !== "object" || entry === null) {
|
|
35
|
+
return { ok: false, reason: "not provided by the platform" };
|
|
36
|
+
}
|
|
37
|
+
const record = entry;
|
|
38
|
+
if (record.ok === false) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
reason: typeof record.reason === "string" ? record.reason : "unavailable"
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (record.ok !== true) {
|
|
45
|
+
return { ok: false, reason: "malformed control" };
|
|
46
|
+
}
|
|
47
|
+
const parsed = schema.safeParse(record.value);
|
|
48
|
+
if (!parsed.success) {
|
|
49
|
+
return { ok: false, reason: `invalid value: ${parsed.error.issues[0]?.message ?? "unknown"}` };
|
|
50
|
+
}
|
|
51
|
+
return { ok: true, value: parsed.data };
|
|
52
|
+
}
|
|
53
|
+
function parseControls(raw) {
|
|
54
|
+
const source = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {};
|
|
55
|
+
const result = {};
|
|
56
|
+
for (const key of CONTROL_KEYS) {
|
|
57
|
+
result[key] = parseOne(source[key], CONTROLS[key].schema);
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
function unavailableControls(reason) {
|
|
62
|
+
const result = {};
|
|
63
|
+
for (const key of CONTROL_KEYS) result[key] = { ok: false, reason };
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
export {
|
|
67
|
+
CONTROLS,
|
|
68
|
+
CONTROL_KEYS,
|
|
69
|
+
isControlKey,
|
|
70
|
+
isWritableControl,
|
|
71
|
+
parseControls,
|
|
72
|
+
unavailableControls
|
|
73
|
+
};
|