@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 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
+ });
@@ -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 };
@@ -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
+ };