@stardeck-customer-apps/controls 0.0.0-stage → 0.3.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 ADDED
@@ -0,0 +1,188 @@
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, how many locations have POS Pro hardware, which
7
+ Accounting edition the business pays for, and which payment provider and methods
8
+ the app uses. Your app asks Stardeck about once a minute and keeps the answer in memory.
9
+ A change on Stardeck reaches the app within about a minute, with no deploy.
10
+
11
+ Never invent controls or flags of your own. The list is fixed by Stardeck and
12
+ shipped in this package. If a behavior needs a switch that is not on the list,
13
+ store it as an app setting instead.
14
+
15
+ ## Reading a control
16
+
17
+ ```ts
18
+ import { getControl, getControls } from "@stardeck-customer-apps/controls/server";
19
+
20
+ const multiEntity = await getControl("plan.multiEntity");
21
+ if (multiEntity.ok && multiEntity.value) {
22
+ // allow a second company
23
+ }
24
+ ```
25
+
26
+ `getControl(key)` never throws. It returns `{ ok: true, value }` or
27
+ `{ ok: false, reason }`. `ok: false` means "unknown right now" (Stardeck could
28
+ not be reached on a cold start, or the value was not provided). It does **not**
29
+ mean the feature is off, and it does not mean the limit is unlimited. Handle it:
30
+
31
+ - Refuse the new thing (a new location, a new company, a new checkout) with a
32
+ retry message.
33
+ - Keep everything that already exists working (sales, bookings, reads).
34
+ - Do not cache `ok: false` yourself.
35
+
36
+ After the first success the app keeps serving the last good value if Stardeck is
37
+ briefly unreachable, so `ok: false` is rare in practice.
38
+
39
+ `getControls()` returns every control at once. The cache lives for the life of
40
+ the server process and is shared by every request. `refreshControls()` asks the
41
+ platform now, ignoring the cache; use it when the platform has just told you a
42
+ value moved (a payment refused with `PROVIDER_CHANGED`). If that request fails,
43
+ the last values stay.
44
+
45
+ ## The controls
46
+
47
+ | Key | Value | App may write |
48
+ | ------------------------ | ----------------------------------------------------------------------------------------------- | ------------- |
49
+ | `flag.paymentsSetupStep` | `boolean` | no |
50
+ | `plan.locations` | `{ included: number \| null, max: number \| null, extraMonthly: { amount, currency } \| null }` | no |
51
+ | `plan.multiEntity` | `boolean` | no |
52
+ | `pos.pro` | `{ locations: number \| null }` | no |
53
+ | `plan.accountingEdition` | `"small" \| "medium" \| null` | no |
54
+ | `payments.provider` | `"beam" \| "stripe" \| null` | yes |
55
+ | `payments.methods` | `{ card: boolean, promptPay: boolean }` | yes |
56
+
57
+ - `plan.locations.included` is what the plan covers. `max` is a hard cap (the
58
+ entry plan has 1); `null` means no cap, and each location past `included` is
59
+ billed at `extraMonthly`. `included: null` means the number was agreed per deal.
60
+ - `pos.pro.locations` is how many locations the business pays for POS Pro (Edge
61
+ receipt printing, Bolt+ card terminals). `null` means no limit; `0` means none
62
+ (the entry plan never includes POS Pro). See "POS Pro hardware" below.
63
+ - `plan.accountingEdition` is the Accounting edition the business pays for, an
64
+ add-on next to its plan. `null` means it has no Accounting plan. See
65
+ "Accounting edition" below.
66
+ - Keys are never removed in later versions, so code written against today's list
67
+ keeps working.
68
+
69
+ ## POS Pro hardware
70
+
71
+ Check `pos.pro` only when someone adds POS hardware (pairs a receipt printer or
72
+ a card terminal) at a location that has none yet. Never check it on a sale, a
73
+ print or a payment: hardware that is already set up keeps working whatever the
74
+ count says, so a business is never stopped mid-service.
75
+
76
+ ```ts
77
+ const posPro = await getControl("pos.pro");
78
+ if (!posPro.ok) {
79
+ // Unknown right now: refuse the add with a retry message (answer 503), and
80
+ // leave the hardware the business already has alone.
81
+ return unavailable("POS Pro could not be checked. Try again in a minute.");
82
+ }
83
+ const { locations } = posPro.value;
84
+ const usesPosPro = await countLocationsWithPosHardware(); // locations with a printer or terminal
85
+ if (locations !== null && !locationAlreadyHasHardware && usesPosPro >= locations) {
86
+ return refuse("Every POS Pro location you pay for is in use. Add POS Pro to this location first.");
87
+ }
88
+ ```
89
+
90
+ A location that already has a printer or terminal can add more without using up
91
+ another POS Pro location.
92
+
93
+ ## Accounting edition
94
+
95
+ Accounting comes in two editions, Small and Medium, each bought as an add-on.
96
+ Finance is on every plan and never depends on this control. Read
97
+ `plan.accountingEdition` to decide which Accounting the app offers:
98
+
99
+ - `"small"` or `"medium"`: offer that edition.
100
+ - `null`: the business has no Accounting plan. Hide Accounting and refuse its
101
+ writes with a message that it needs an Accounting plan. Records it made while
102
+ it had one stay readable.
103
+ - `ok: false`: unknown right now, which is not the same as `null`. Refuse new
104
+ accounting records (journal entries, period closes, filings) with a retry
105
+ message (answer 503), and keep everything that exists readable. Never hide
106
+ Accounting or treat the business as having none because of `ok: false`.
107
+
108
+ ```ts
109
+ const edition = await getControl("plan.accountingEdition");
110
+ if (!edition.ok) {
111
+ return unavailable("Accounting could not be checked. Try again in a minute.");
112
+ }
113
+ if (edition.value === null) {
114
+ return refuse("Accounting needs an Accounting plan.");
115
+ }
116
+ // edition.value is "small" or "medium"
117
+ ```
118
+
119
+ Types and the list itself are in the root export:
120
+
121
+ ```ts
122
+ import { CONTROLS, parseControls, type ControlKey } from "@stardeck-customer-apps/controls";
123
+ ```
124
+
125
+ ## Changing provider or methods
126
+
127
+ Only `payments.provider` and `payments.methods` can be changed from the app, and
128
+ only on behalf of a signed-in person. Pass that person's session token. Stardeck
129
+ checks that this person may change the app's settings; the app's own roles are
130
+ never enough.
131
+
132
+ ```ts
133
+ import { cookies } from "next/headers";
134
+ import { setControl } from "@stardeck-customer-apps/controls/server";
135
+
136
+ // The token is the project-auth session cookie "__stardeck:sess", the same one
137
+ // AuthManagementClient sends as X-Stardeck-User-Token.
138
+ const userToken = (await cookies()).get("__stardeck:sess")?.value;
139
+ if (!userToken) return unauthorized();
140
+
141
+ const result = await setControl("payments.provider", "stripe", { userToken });
142
+ if (!result.ok) {
143
+ // result.error.code: NOT_WRITABLE | INVALID_VALUE | UNAUTHENTICATED | FORBIDDEN |
144
+ // WRITE_NOT_ALLOWED_HERE | PROVIDER_NOT_READY | UNAVAILABLE
145
+ // PROVIDER_NOT_READY carries result.error.connectUrl: send the person there to
146
+ // finish connecting, and they come back with the provider switched on.
147
+ // WRITE_NOT_ALLOWED_HERE: previews and stagings change this on Stardeck instead.
148
+ return refuse(result.error);
149
+ }
150
+ // result.controls is the fresh set; the app's cache already has it.
151
+ ```
152
+
153
+ Show `error.message` to the person. Do not retry `FORBIDDEN` or
154
+ `WRITE_NOT_ALLOWED_HERE`. A successful write refreshes this server immediately;
155
+ other server instances catch up within a minute. Payment creation is checked
156
+ live by Stardeck, so a stale instance cannot charge on the old provider.
157
+
158
+ ## Reporting usage
159
+
160
+ Stardeck bills extra locations from what the app reports. Register a provider
161
+ once at startup; it runs on every sync (about once a minute per server):
162
+
163
+ ```ts
164
+ import { setUsageProvider } from "@stardeck-customer-apps/controls/server";
165
+
166
+ setUsageProvider(async () => ({ locations: await countActiveLocations() }));
167
+ ```
168
+
169
+ Report non-negative integers only, counted from the app's own rows. Keep the
170
+ count to one quick query: if the provider throws or takes longer than a second,
171
+ the error is logged and that sync goes without usage.
172
+
173
+ ## Configuration
174
+
175
+ The client reads `CONTROL_PLANE_URL`,
176
+ `ORGANIZATION_ID`, `PROJECT_ID`, `DEPLOYMENT_ID` and `DEPLOYMENT_SECRET`, the same
177
+ deployment env the other Stardeck SDKs use. If any is missing, every control
178
+ reads `{ ok: false, reason: "not configured" }` and one warning is logged. For
179
+ custom wiring use `createControlsClient({ controlPlaneUrl, organizationId, projectId, deploymentId, deploymentSecret })`.
180
+
181
+ ## Testing
182
+
183
+ `@stardeck-customer-apps/testing` simulates the sync and write routes:
184
+ `createTestApp({ controls: { "plan.multiEntity": false } })`, then
185
+ `app.controls.set(key, value)`, `app.controls.setUnavailable(key)`,
186
+ `app.controls.lastUsage()`, `app.controls.writes()` and
187
+ `app.controls.refuseNextWrite({ code, message, connectUrl })`. A `set` is visible
188
+ to the next `getControl` call immediately. Always cover the `ok: false` path.
package/dist/index.cjs ADDED
@@ -0,0 +1,121 @@
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
+ "pos.pro": {
57
+ schema: import_zod.z.object({
58
+ /** POS Pro locations paid for. null = unlimited (legacy plans, internal orgs). */
59
+ locations: import_zod.z.number().int().nonnegative().nullable()
60
+ }),
61
+ appWrite: false
62
+ },
63
+ /**
64
+ * The Accounting Module edition the org pays for, each an add-on next to its plan. null means
65
+ * no Accounting plan: the app has no Accounting. `{ ok: false }` means unknown right now:
66
+ * refuse new accounting records and keep existing ones readable.
67
+ */
68
+ "plan.accountingEdition": {
69
+ schema: import_zod.z.enum(["small", "medium"]).nullable(),
70
+ appWrite: false
71
+ }
72
+ };
73
+ var CONTROL_KEYS = Object.keys(CONTROLS);
74
+ function isControlKey(key) {
75
+ return Object.prototype.hasOwnProperty.call(CONTROLS, key);
76
+ }
77
+ function isWritableControl(key) {
78
+ return isControlKey(key) && CONTROLS[key].appWrite === true;
79
+ }
80
+ function parseOne(entry, schema) {
81
+ if (typeof entry !== "object" || entry === null) {
82
+ return { ok: false, reason: "not provided by the platform" };
83
+ }
84
+ const record = entry;
85
+ if (record.ok === false) {
86
+ return {
87
+ ok: false,
88
+ reason: typeof record.reason === "string" ? record.reason : "unavailable"
89
+ };
90
+ }
91
+ if (record.ok !== true) {
92
+ return { ok: false, reason: "malformed control" };
93
+ }
94
+ const parsed = schema.safeParse(record.value);
95
+ if (!parsed.success) {
96
+ return { ok: false, reason: `invalid value: ${parsed.error.issues[0]?.message ?? "unknown"}` };
97
+ }
98
+ return { ok: true, value: parsed.data };
99
+ }
100
+ function parseControls(raw) {
101
+ const source = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {};
102
+ const result = {};
103
+ for (const key of CONTROL_KEYS) {
104
+ result[key] = parseOne(source[key], CONTROLS[key].schema);
105
+ }
106
+ return result;
107
+ }
108
+ function unavailableControls(reason) {
109
+ const result = {};
110
+ for (const key of CONTROL_KEYS) result[key] = { ok: false, reason };
111
+ return result;
112
+ }
113
+ // Annotate the CommonJS export names for ESM import in node:
114
+ 0 && (module.exports = {
115
+ CONTROLS,
116
+ CONTROL_KEYS,
117
+ isControlKey,
118
+ isWritableControl,
119
+ parseControls,
120
+ unavailableControls
121
+ });
@@ -0,0 +1,121 @@
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
+ readonly "pos.pro": {
52
+ readonly schema: z.ZodObject<{
53
+ locations: z.ZodNullable<z.ZodNumber>;
54
+ }, z.core.$strip>;
55
+ readonly appWrite: false;
56
+ };
57
+ /**
58
+ * The Accounting Module edition the org pays for, each an add-on next to its plan. null means
59
+ * no Accounting plan: the app has no Accounting. `{ ok: false }` means unknown right now:
60
+ * refuse new accounting records and keep existing ones readable.
61
+ */
62
+ readonly "plan.accountingEdition": {
63
+ readonly schema: z.ZodNullable<z.ZodEnum<{
64
+ small: "small";
65
+ medium: "medium";
66
+ }>>;
67
+ readonly appWrite: false;
68
+ };
69
+ };
70
+ type ControlKey = keyof typeof CONTROLS;
71
+ type ControlValue<K extends ControlKey> = z.infer<(typeof CONTROLS)[K]["schema"]>;
72
+ /** One control as the app sees it. `ok: false` means "unknown right now". */
73
+ type ControlResult<K extends ControlKey> = {
74
+ ok: true;
75
+ value: ControlValue<K>;
76
+ } | {
77
+ ok: false;
78
+ reason: string;
79
+ };
80
+ type Controls = {
81
+ [K in ControlKey]: ControlResult<K>;
82
+ };
83
+ type WritableControlKey = {
84
+ [K in ControlKey]: (typeof CONTROLS)[K]["appWrite"] extends true ? K : never;
85
+ }[ControlKey];
86
+ /** What the app reports to the platform on every sync. Non-negative integers. */
87
+ type ControlsUsage = {
88
+ locations?: number;
89
+ };
90
+ /** POST /api/deployments/{deploymentId}/controls body. */
91
+ type ControlsSyncRequest = {
92
+ usage?: ControlsUsage;
93
+ };
94
+ /** POST response. Each value is `{ ok: true, value }` or `{ ok: false, reason }`. */
95
+ type ControlsSyncResponse = {
96
+ controls: Record<string, unknown>;
97
+ };
98
+ /** PUT /api/deployments/{deploymentId}/controls/{key} body. The person's session goes in `X-Stardeck-User-Token`. */
99
+ type ControlWriteRequest = {
100
+ value: unknown;
101
+ };
102
+ type ControlWriteErrorCode = "NOT_WRITABLE" | "INVALID_VALUE" | "UNAUTHENTICATED" | "FORBIDDEN" | "WRITE_NOT_ALLOWED_HERE" | "PROVIDER_NOT_READY" | "UNAVAILABLE";
103
+ type ControlWriteError = {
104
+ code: ControlWriteErrorCode;
105
+ message: string;
106
+ /** Where the person finishes connecting a provider, when that is the blocker. */
107
+ connectUrl?: string;
108
+ };
109
+ declare const CONTROL_KEYS: ControlKey[];
110
+ declare function isControlKey(key: string): key is ControlKey;
111
+ declare function isWritableControl(key: string): key is WritableControlKey;
112
+ /**
113
+ * Validates the platform's `controls` map. Each key is validated on its own: a
114
+ * missing or invalid key becomes `{ ok: false, reason }` and never poisons the
115
+ * others. Keys this version does not know are ignored.
116
+ */
117
+ declare function parseControls(raw: unknown): Controls;
118
+ /** Every control unavailable for one reason (cold start with the platform down, or not configured). */
119
+ declare function unavailableControls(reason: string): Controls;
120
+
121
+ 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 };
@@ -0,0 +1,121 @@
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
+ readonly "pos.pro": {
52
+ readonly schema: z.ZodObject<{
53
+ locations: z.ZodNullable<z.ZodNumber>;
54
+ }, z.core.$strip>;
55
+ readonly appWrite: false;
56
+ };
57
+ /**
58
+ * The Accounting Module edition the org pays for, each an add-on next to its plan. null means
59
+ * no Accounting plan: the app has no Accounting. `{ ok: false }` means unknown right now:
60
+ * refuse new accounting records and keep existing ones readable.
61
+ */
62
+ readonly "plan.accountingEdition": {
63
+ readonly schema: z.ZodNullable<z.ZodEnum<{
64
+ small: "small";
65
+ medium: "medium";
66
+ }>>;
67
+ readonly appWrite: false;
68
+ };
69
+ };
70
+ type ControlKey = keyof typeof CONTROLS;
71
+ type ControlValue<K extends ControlKey> = z.infer<(typeof CONTROLS)[K]["schema"]>;
72
+ /** One control as the app sees it. `ok: false` means "unknown right now". */
73
+ type ControlResult<K extends ControlKey> = {
74
+ ok: true;
75
+ value: ControlValue<K>;
76
+ } | {
77
+ ok: false;
78
+ reason: string;
79
+ };
80
+ type Controls = {
81
+ [K in ControlKey]: ControlResult<K>;
82
+ };
83
+ type WritableControlKey = {
84
+ [K in ControlKey]: (typeof CONTROLS)[K]["appWrite"] extends true ? K : never;
85
+ }[ControlKey];
86
+ /** What the app reports to the platform on every sync. Non-negative integers. */
87
+ type ControlsUsage = {
88
+ locations?: number;
89
+ };
90
+ /** POST /api/deployments/{deploymentId}/controls body. */
91
+ type ControlsSyncRequest = {
92
+ usage?: ControlsUsage;
93
+ };
94
+ /** POST response. Each value is `{ ok: true, value }` or `{ ok: false, reason }`. */
95
+ type ControlsSyncResponse = {
96
+ controls: Record<string, unknown>;
97
+ };
98
+ /** PUT /api/deployments/{deploymentId}/controls/{key} body. The person's session goes in `X-Stardeck-User-Token`. */
99
+ type ControlWriteRequest = {
100
+ value: unknown;
101
+ };
102
+ type ControlWriteErrorCode = "NOT_WRITABLE" | "INVALID_VALUE" | "UNAUTHENTICATED" | "FORBIDDEN" | "WRITE_NOT_ALLOWED_HERE" | "PROVIDER_NOT_READY" | "UNAVAILABLE";
103
+ type ControlWriteError = {
104
+ code: ControlWriteErrorCode;
105
+ message: string;
106
+ /** Where the person finishes connecting a provider, when that is the blocker. */
107
+ connectUrl?: string;
108
+ };
109
+ declare const CONTROL_KEYS: ControlKey[];
110
+ declare function isControlKey(key: string): key is ControlKey;
111
+ declare function isWritableControl(key: string): key is WritableControlKey;
112
+ /**
113
+ * Validates the platform's `controls` map. Each key is validated on its own: a
114
+ * missing or invalid key becomes `{ ok: false, reason }` and never poisons the
115
+ * others. Keys this version does not know are ignored.
116
+ */
117
+ declare function parseControls(raw: unknown): Controls;
118
+ /** Every control unavailable for one reason (cold start with the platform down, or not configured). */
119
+ declare function unavailableControls(reason: string): Controls;
120
+
121
+ 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,89 @@
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
+ "pos.pro": {
26
+ schema: z.object({
27
+ /** POS Pro locations paid for. null = unlimited (legacy plans, internal orgs). */
28
+ locations: z.number().int().nonnegative().nullable()
29
+ }),
30
+ appWrite: false
31
+ },
32
+ /**
33
+ * The Accounting Module edition the org pays for, each an add-on next to its plan. null means
34
+ * no Accounting plan: the app has no Accounting. `{ ok: false }` means unknown right now:
35
+ * refuse new accounting records and keep existing ones readable.
36
+ */
37
+ "plan.accountingEdition": {
38
+ schema: z.enum(["small", "medium"]).nullable(),
39
+ appWrite: false
40
+ }
41
+ };
42
+ var CONTROL_KEYS = Object.keys(CONTROLS);
43
+ function isControlKey(key) {
44
+ return Object.prototype.hasOwnProperty.call(CONTROLS, key);
45
+ }
46
+ function isWritableControl(key) {
47
+ return isControlKey(key) && CONTROLS[key].appWrite === true;
48
+ }
49
+ function parseOne(entry, schema) {
50
+ if (typeof entry !== "object" || entry === null) {
51
+ return { ok: false, reason: "not provided by the platform" };
52
+ }
53
+ const record = entry;
54
+ if (record.ok === false) {
55
+ return {
56
+ ok: false,
57
+ reason: typeof record.reason === "string" ? record.reason : "unavailable"
58
+ };
59
+ }
60
+ if (record.ok !== true) {
61
+ return { ok: false, reason: "malformed control" };
62
+ }
63
+ const parsed = schema.safeParse(record.value);
64
+ if (!parsed.success) {
65
+ return { ok: false, reason: `invalid value: ${parsed.error.issues[0]?.message ?? "unknown"}` };
66
+ }
67
+ return { ok: true, value: parsed.data };
68
+ }
69
+ function parseControls(raw) {
70
+ const source = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {};
71
+ const result = {};
72
+ for (const key of CONTROL_KEYS) {
73
+ result[key] = parseOne(source[key], CONTROLS[key].schema);
74
+ }
75
+ return result;
76
+ }
77
+ function unavailableControls(reason) {
78
+ const result = {};
79
+ for (const key of CONTROL_KEYS) result[key] = { ok: false, reason };
80
+ return result;
81
+ }
82
+ export {
83
+ CONTROLS,
84
+ CONTROL_KEYS,
85
+ isControlKey,
86
+ isWritableControl,
87
+ parseControls,
88
+ unavailableControls
89
+ };