@frockbot/plugin-admin 0.0.0 → 0.1.1

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/frockbot.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "schemaVersion": 3,
3
+ "id": "admin",
4
+ "displayName": "Deployment administration",
5
+ "version": "0.0.1",
6
+ "compatibility": { "frockbot": ">=0.0.1" },
7
+ "dependencies": {
8
+ "shell": ">=0.0.1",
9
+ "ui-theme": ">=0.0.1"
10
+ },
11
+ "contributions": {
12
+ "backend": [{ "entry": "./backend", "host": "gateway" }],
13
+ "client": { "entry": "./client", "mounts": [], "outlets": [] }
14
+ },
15
+ "permissions": []
16
+ }
package/package.json CHANGED
@@ -1,14 +1,42 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-admin",
3
- "version": "0.0.0",
4
- "description": "Placeholder reserving this name for trusted publishing. Superseded by the first release.",
5
- "license": "UNLICENSED",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./backend": "./src/backend.ts",
9
+ "./client": "./src/client/index.ts",
10
+ "./frockbot.json": "./frockbot.json",
11
+ "./manifest": "./src/manifest.ts",
12
+ "./package.json": "./package.json",
13
+ "./shared": "./src/shared.ts"
14
+ },
15
+ "frockbot": {
16
+ "manifest": "./frockbot.json"
17
+ },
18
+ "scripts": {
19
+ "test": "bun test src",
20
+ "typecheck": "vue-tsc --noEmit -p tsconfig.json"
21
+ },
22
+ "dependencies": {
23
+ "@frockbot/client-core": "0.1.1",
24
+ "@frockbot/client-ui": "0.1.1",
25
+ "@frockbot/plugin-shell": "0.1.1",
26
+ "cordis": "4.0.0-rc.8",
27
+ "vue": "3.5.41"
28
+ },
29
+ "devDependencies": {
30
+ "@types/bun": "1.4.0",
31
+ "typescript": "5.9.3",
32
+ "vue-tsc": "3.3.10"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
6
37
  "repository": {
7
38
  "type": "git",
8
39
  "url": "git+https://github.com/timoconnellaus/frockbot.git",
9
40
  "directory": "packages/plugin-admin"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
41
  }
14
42
  }
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createAdminBackendContribution } from "./backend.js";
3
+ import {
4
+ DeploymentPolicyConflictError,
5
+ type DeploymentPolicyV1,
6
+ } from "./shared.js";
7
+
8
+ function initialPolicy(): DeploymentPolicyV1 {
9
+ return {
10
+ schemaVersion: 1,
11
+ revision: 0,
12
+ signups: { open: false },
13
+ updatedAt: "2026-09-01T00:00:00.000Z",
14
+ updatedBy: "deployment-default",
15
+ };
16
+ }
17
+
18
+ describe("admin gateway contribution", () => {
19
+ test("refuses non-admins before reading deployment policy", async () => {
20
+ let reads = 0;
21
+ const contribution = createAdminBackendContribution({
22
+ readDeploymentPolicy: () => {
23
+ reads += 1;
24
+ return Promise.resolve(initialPolicy());
25
+ },
26
+ setDeploymentSignups: () => Promise.resolve(initialPolicy()),
27
+ });
28
+
29
+ const response = await contribution.route(
30
+ new Request("https://frockbot.test/api/admin/policy"),
31
+ new URL("https://frockbot.test/api/admin/policy"),
32
+ { userId: "ordinary-user", client: "browser", isAdmin: false },
33
+ );
34
+
35
+ expect(response?.status).toBe(403);
36
+ expect(reads).toBe(0);
37
+ });
38
+
39
+ test("reads and updates the policy with an optimistic revision", async () => {
40
+ let policy = initialPolicy();
41
+ const contribution = createAdminBackendContribution({
42
+ readDeploymentPolicy: () => Promise.resolve(policy),
43
+ setDeploymentSignups: (command, updatedBy) => {
44
+ if (command.revision !== policy.revision) {
45
+ throw new DeploymentPolicyConflictError(policy.revision);
46
+ }
47
+ policy = {
48
+ schemaVersion: 1,
49
+ revision: policy.revision + 1,
50
+ signups: { open: command.open },
51
+ updatedAt: "2026-09-01T01:00:00.000Z",
52
+ updatedBy,
53
+ };
54
+ return Promise.resolve(policy);
55
+ },
56
+ });
57
+ const context = {
58
+ userId: "owner-id",
59
+ client: "browser" as const,
60
+ isAdmin: true,
61
+ };
62
+
63
+ const read = await contribution.route(
64
+ new Request("https://frockbot.test/api/admin/policy"),
65
+ new URL("https://frockbot.test/api/admin/policy"),
66
+ context,
67
+ );
68
+ expect(await read?.json()).toEqual(initialPolicy());
69
+
70
+ const update = await contribution.route(
71
+ new Request("https://frockbot.test/api/admin/policy", {
72
+ method: "POST",
73
+ headers: { "content-type": "application/json" },
74
+ body: JSON.stringify({
75
+ schemaVersion: 1,
76
+ type: "deployment/set-signups",
77
+ open: true,
78
+ revision: 0,
79
+ }),
80
+ }),
81
+ new URL("https://frockbot.test/api/admin/policy"),
82
+ context,
83
+ );
84
+ expect(update?.status).toBe(200);
85
+ expect(await update?.json()).toMatchObject({
86
+ revision: 1,
87
+ signups: { open: true },
88
+ updatedBy: "owner-id",
89
+ });
90
+
91
+ const conflict = await contribution.route(
92
+ new Request("https://frockbot.test/api/admin/policy", {
93
+ method: "POST",
94
+ headers: { "content-type": "application/json" },
95
+ body: JSON.stringify({
96
+ schemaVersion: 1,
97
+ type: "deployment/set-signups",
98
+ open: false,
99
+ revision: 0,
100
+ }),
101
+ }),
102
+ new URL("https://frockbot.test/api/admin/policy"),
103
+ context,
104
+ );
105
+ expect(conflict?.status).toBe(409);
106
+ expect(await conflict?.json()).toMatchObject({
107
+ code: "revision-conflict",
108
+ currentRevision: 1,
109
+ });
110
+ });
111
+ });
package/src/backend.ts ADDED
@@ -0,0 +1,120 @@
1
+ import type { Plugin } from "cordis";
2
+ import {
3
+ decodeDeploymentPolicyV1,
4
+ decodeSetSignupsCommandV1,
5
+ type DeploymentPolicyV1,
6
+ type SetSignupsCommandV1,
7
+ } from "./shared.js";
8
+
9
+ export interface AdminGatewayHost {
10
+ readDeploymentPolicy(): Promise<DeploymentPolicyV1>;
11
+ setDeploymentSignups(
12
+ command: SetSignupsCommandV1,
13
+ updatedBy: string,
14
+ ): Promise<DeploymentPolicyV1>;
15
+ }
16
+
17
+ export interface AdminBackendRouteContribution {
18
+ packageId: string;
19
+ route(
20
+ request: Request,
21
+ url: URL,
22
+ context: {
23
+ userId?: string;
24
+ client: "browser" | "desktop";
25
+ isAdmin: boolean;
26
+ },
27
+ ): Promise<Response | undefined>;
28
+ }
29
+
30
+ function jsonError(status: number, message: string): Response {
31
+ return Response.json({ error: message }, { status });
32
+ }
33
+
34
+ function isPolicyConflict(error: unknown): boolean {
35
+ return (
36
+ typeof error === "object" &&
37
+ error !== null &&
38
+ "name" in error &&
39
+ error.name === "DeploymentPolicyConflictError"
40
+ );
41
+ }
42
+
43
+ export function createAdminBackendContribution(
44
+ host: AdminGatewayHost,
45
+ ): AdminBackendRouteContribution {
46
+ return {
47
+ packageId: "admin",
48
+ async route(request, url, context) {
49
+ if (url.pathname !== "/api/admin/policy") return undefined;
50
+ if (!context.userId || !context.isAdmin) {
51
+ return jsonError(403, "Admin access is required");
52
+ }
53
+ if ([...url.searchParams.keys()].length > 0) {
54
+ return jsonError(400, "Admin policy query is invalid");
55
+ }
56
+ if (request.method === "GET") {
57
+ try {
58
+ return Response.json(
59
+ decodeDeploymentPolicyV1(await host.readDeploymentPolicy()),
60
+ );
61
+ } catch (error) {
62
+ return jsonError(
63
+ 500,
64
+ error instanceof Error
65
+ ? error.message
66
+ : "Admin policy could not be read",
67
+ );
68
+ }
69
+ }
70
+ if (request.method !== "POST") {
71
+ return jsonError(405, "method not allowed");
72
+ }
73
+ let command: SetSignupsCommandV1;
74
+ try {
75
+ command = decodeSetSignupsCommandV1(await request.json());
76
+ } catch (error) {
77
+ return jsonError(
78
+ 400,
79
+ error instanceof Error ? error.message : "Admin policy was refused",
80
+ );
81
+ }
82
+ try {
83
+ return Response.json(
84
+ decodeDeploymentPolicyV1(
85
+ await host.setDeploymentSignups(command, context.userId),
86
+ ),
87
+ );
88
+ } catch (error) {
89
+ if (isPolicyConflict(error)) {
90
+ const current = decodeDeploymentPolicyV1(
91
+ await host.readDeploymentPolicy(),
92
+ );
93
+ return Response.json(
94
+ {
95
+ error: `deployment policy revision is ${current.revision}`,
96
+ code: "revision-conflict",
97
+ currentRevision: current.revision,
98
+ },
99
+ { status: 409 },
100
+ );
101
+ }
102
+ return jsonError(
103
+ 500,
104
+ error instanceof Error
105
+ ? error.message
106
+ : "Admin policy could not be changed",
107
+ );
108
+ }
109
+ },
110
+ };
111
+ }
112
+
113
+ export namespace createAdminBackendContribution {
114
+ export function plugin(
115
+ host: AdminGatewayHost,
116
+ lifecycle: { mount(value: AdminBackendRouteContribution): () => void },
117
+ ): Plugin {
118
+ return () => lifecycle.mount(createAdminBackendContribution(host));
119
+ }
120
+ }
@@ -0,0 +1,178 @@
1
+ <script setup lang="ts">
2
+ import { UiButton } from "@frockbot/client-ui";
3
+ import { inject, onMounted, ref } from "vue";
4
+ import {
5
+ decodeDeploymentPolicyV1,
6
+ type DeploymentPolicyV1,
7
+ } from "../shared.js";
8
+ import { adminRequestKey } from "./state.js";
9
+
10
+ const providedRequest = inject(adminRequestKey);
11
+ if (!providedRequest)
12
+ throw new Error("admin client transport was not provided");
13
+ const request: NonNullable<typeof providedRequest> = providedRequest;
14
+
15
+ const policy = ref<DeploymentPolicyV1>();
16
+ const loading = ref(true);
17
+ const saving = ref(false);
18
+ const error = ref<string>();
19
+
20
+ function changedLine(value: DeploymentPolicyV1): string {
21
+ const changed = new Intl.DateTimeFormat(undefined, {
22
+ dateStyle: "medium",
23
+ timeStyle: "short",
24
+ }).format(new Date(value.updatedAt));
25
+ return `Last changed ${changed} by ${value.updatedBy}.`;
26
+ }
27
+
28
+ async function load(): Promise<void> {
29
+ loading.value = true;
30
+ try {
31
+ policy.value = decodeDeploymentPolicyV1(await request("/api/admin/policy"));
32
+ error.value = undefined;
33
+ } catch (cause) {
34
+ error.value =
35
+ cause instanceof Error ? cause.message : "Could not load signup policy";
36
+ } finally {
37
+ loading.value = false;
38
+ }
39
+ }
40
+
41
+ async function setOpen(open: boolean): Promise<void> {
42
+ const current = policy.value;
43
+ if (!current || saving.value || current.signups.open === open) return;
44
+ saving.value = true;
45
+ error.value = undefined;
46
+ try {
47
+ policy.value = decodeDeploymentPolicyV1(
48
+ await request(
49
+ "/api/admin/policy",
50
+ "POST",
51
+ JSON.stringify({
52
+ schemaVersion: 1,
53
+ type: "deployment/set-signups",
54
+ open,
55
+ revision: current.revision,
56
+ }),
57
+ ),
58
+ );
59
+ } catch (cause) {
60
+ const message =
61
+ cause instanceof Error ? cause.message : "Could not change signup policy";
62
+ await load();
63
+ error.value = message;
64
+ } finally {
65
+ saving.value = false;
66
+ }
67
+ }
68
+
69
+ function changeSignups(event: Event): void {
70
+ const target = event.currentTarget;
71
+ if (!(target instanceof HTMLInputElement)) return;
72
+ void setOpen(target.checked);
73
+ }
74
+
75
+ onMounted(load);
76
+ </script>
77
+
78
+ <template>
79
+ <section class="admin-surface" aria-label="Deployment policy">
80
+ <p class="admin-surface__explanation">
81
+ Control whether people without an existing FrockBot User can sign in.
82
+ Existing Users and admins are always admitted.
83
+ </p>
84
+
85
+ <p v-if="loading" class="admin-surface__status" aria-live="polite">
86
+ Loading signup policy…
87
+ </p>
88
+
89
+ <div v-else-if="policy" class="admin-surface__policy">
90
+ <label class="admin-surface__toggle">
91
+ <span>
92
+ <strong>Accept new signups</strong>
93
+ <small>Let new Users enter this deployment.</small>
94
+ </span>
95
+ <input
96
+ type="checkbox"
97
+ :checked="policy.signups.open"
98
+ :disabled="saving"
99
+ @change="changeSignups"
100
+ />
101
+ </label>
102
+ <p class="admin-surface__changed">{{ changedLine(policy) }}</p>
103
+ </div>
104
+
105
+ <p v-if="error" class="admin-surface__error" role="alert">{{ error }}</p>
106
+ <UiButton v-if="error && !policy" type="button" @click="load">
107
+ Try again
108
+ </UiButton>
109
+ </section>
110
+ </template>
111
+
112
+ <style scoped>
113
+ .admin-surface {
114
+ display: flex;
115
+ flex-direction: column;
116
+ gap: 16px;
117
+ padding: 16px;
118
+ }
119
+
120
+ .admin-surface__explanation,
121
+ .admin-surface__status,
122
+ .admin-surface__changed,
123
+ .admin-surface__error {
124
+ margin: 0;
125
+ font-size: var(--frock-text-sm);
126
+ line-height: var(--frock-leading-normal);
127
+ }
128
+
129
+ .admin-surface__explanation,
130
+ .admin-surface__status,
131
+ .admin-surface__changed {
132
+ color: var(--frock-text-muted);
133
+ }
134
+
135
+ .admin-surface__policy {
136
+ display: flex;
137
+ flex-direction: column;
138
+ gap: 10px;
139
+ padding: 14px;
140
+ border: 1px solid var(--frock-border);
141
+ border-radius: var(--frock-radius-card);
142
+ background: var(--frock-surface-subtle);
143
+ }
144
+
145
+ .admin-surface__toggle {
146
+ display: flex;
147
+ align-items: center;
148
+ justify-content: space-between;
149
+ gap: 16px;
150
+ cursor: pointer;
151
+ }
152
+
153
+ .admin-surface__toggle span {
154
+ display: flex;
155
+ flex-direction: column;
156
+ gap: 3px;
157
+ }
158
+
159
+ .admin-surface__toggle strong {
160
+ font-size: var(--frock-text-md);
161
+ font-weight: 600;
162
+ }
163
+
164
+ .admin-surface__toggle small {
165
+ color: var(--frock-text-muted);
166
+ font-size: var(--frock-text-sm);
167
+ }
168
+
169
+ .admin-surface__toggle input {
170
+ width: var(--frock-control-sm);
171
+ height: var(--frock-control-sm);
172
+ accent-color: var(--frock-action-primary);
173
+ }
174
+
175
+ .admin-surface__error {
176
+ color: var(--frock-danger-text);
177
+ }
178
+ </style>
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ clientSurfaceRegistryKey,
4
+ type ClientPluginContext,
5
+ } from "@frockbot/client-core";
6
+ import { createClientSurfaceRegistry } from "@frockbot/client-ui";
7
+ import { adminClientPlugin } from "./index.js";
8
+
9
+ describe("admin client contribution", () => {
10
+ test("registers and disposes the Admin overlay", () => {
11
+ const surfaces = createClientSurfaceRegistry();
12
+ const context: ClientPluginContext = {
13
+ transport: {
14
+ turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
15
+ hostedRequest: () => Promise.resolve({}),
16
+ },
17
+ inject: (key) => {
18
+ if (key !== clientSurfaceRegistryKey) {
19
+ throw new Error("unexpected client provider");
20
+ }
21
+ return surfaces as never;
22
+ },
23
+ provide: () => () => {},
24
+ slot: () => () => {},
25
+ };
26
+
27
+ const result = adminClientPlugin(context);
28
+ if (!Array.isArray(result)) throw new Error("expected owned registrations");
29
+ expect(surfaces.has("admin")).toBe(true);
30
+
31
+ for (const dispose of result.toReversed()) dispose();
32
+ expect(surfaces.has("admin")).toBe(false);
33
+ });
34
+ });
@@ -0,0 +1,28 @@
1
+ /// <reference path="../env.d.ts" />
2
+
3
+ import {
4
+ clientSurfaceRegistryKey,
5
+ type ClientPlugin,
6
+ } from "@frockbot/client-core";
7
+ import AdminSurface from "./AdminSurface.vue";
8
+ import { adminRequestKey } from "./state.js";
9
+
10
+ export const ADMIN_SURFACE_ID = "admin";
11
+
12
+ export const adminClientPlugin: ClientPlugin = (ctx) => {
13
+ if (!ctx.transport.hostedRequest) {
14
+ throw new Error("Admin hosted transport is unavailable");
15
+ }
16
+ const request = ctx.transport.hostedRequest.bind(ctx.transport);
17
+ const surfaces = ctx.inject(clientSurfaceRegistryKey);
18
+ return [
19
+ ctx.provide(adminRequestKey, request),
20
+ surfaces.register({
21
+ id: ADMIN_SURFACE_ID,
22
+ title: "Admin",
23
+ component: AdminSurface,
24
+ }),
25
+ ];
26
+ };
27
+
28
+ export default adminClientPlugin;
@@ -0,0 +1,11 @@
1
+ import type { InjectionKey } from "vue";
2
+
3
+ export type AdminRequest = (
4
+ path: string,
5
+ method?: "GET" | "POST",
6
+ body?: string,
7
+ ) => Promise<unknown>;
8
+
9
+ export const adminRequestKey: InjectionKey<AdminRequest> = Symbol(
10
+ "frockbot.admin-request",
11
+ );
package/src/env.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ declare module "*.vue" {
2
+ import type { DefineComponent } from "vue";
3
+
4
+ const component: DefineComponent;
5
+ export default component;
6
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { default as manifest } from "./manifest.js";
2
+ export * from "./shared.js";
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;
@@ -0,0 +1,66 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ decodeDeploymentPolicyV1,
4
+ decodeSetSignupsCommandV1,
5
+ decodeSetSignupsRequestV1,
6
+ } from "./shared.js";
7
+
8
+ const policy = {
9
+ schemaVersion: 1,
10
+ revision: 2,
11
+ signups: { open: false },
12
+ updatedAt: "2026-09-01T00:00:00.000Z",
13
+ updatedBy: "owner@example.com",
14
+ } as const;
15
+
16
+ describe("deployment policy codecs", () => {
17
+ test("decode the exact policy and signup command shapes", () => {
18
+ expect(decodeDeploymentPolicyV1(policy)).toEqual(policy);
19
+ expect(
20
+ decodeSetSignupsCommandV1({
21
+ schemaVersion: 1,
22
+ type: "deployment/set-signups",
23
+ open: true,
24
+ revision: 2,
25
+ }),
26
+ ).toEqual({
27
+ schemaVersion: 1,
28
+ type: "deployment/set-signups",
29
+ open: true,
30
+ revision: 2,
31
+ });
32
+ expect(
33
+ decodeSetSignupsRequestV1({
34
+ schemaVersion: 1,
35
+ command: {
36
+ schemaVersion: 1,
37
+ type: "deployment/set-signups",
38
+ open: true,
39
+ revision: 2,
40
+ },
41
+ updatedBy: "owner-id",
42
+ }),
43
+ ).toMatchObject({ updatedBy: "owner-id", command: { open: true } });
44
+ });
45
+
46
+ test("rejects unknown fields at every seam", () => {
47
+ expect(() => decodeDeploymentPolicyV1({ ...policy, extra: true })).toThrow(
48
+ "unknown fields",
49
+ );
50
+ expect(() =>
51
+ decodeDeploymentPolicyV1({
52
+ ...policy,
53
+ signups: { open: false, extra: true },
54
+ }),
55
+ ).toThrow("unknown fields");
56
+ expect(() =>
57
+ decodeSetSignupsCommandV1({
58
+ schemaVersion: 1,
59
+ type: "deployment/set-signups",
60
+ open: true,
61
+ revision: 2,
62
+ expectedRevision: 2,
63
+ }),
64
+ ).toThrow("unknown fields");
65
+ });
66
+ });
package/src/shared.ts ADDED
@@ -0,0 +1,162 @@
1
+ export interface DeploymentPolicyV1 {
2
+ schemaVersion: 1;
3
+ revision: number;
4
+ signups: { open: boolean };
5
+ updatedAt: string;
6
+ updatedBy: string;
7
+ }
8
+
9
+ export interface SetSignupsCommandV1 {
10
+ schemaVersion: 1;
11
+ type: "deployment/set-signups";
12
+ open: boolean;
13
+ revision: number;
14
+ }
15
+
16
+ export interface SetSignupsRequestV1 {
17
+ schemaVersion: 1;
18
+ command: SetSignupsCommandV1;
19
+ updatedBy: string;
20
+ }
21
+
22
+ function record(value: unknown, label: string): Record<string, unknown> {
23
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
24
+ throw new Error(`${label} must be an object`);
25
+ }
26
+ return value as Record<string, unknown>;
27
+ }
28
+
29
+ function exactKeys(
30
+ value: Record<string, unknown>,
31
+ expected: readonly string[],
32
+ label: string,
33
+ ): void {
34
+ const keys = Object.keys(value);
35
+ if (
36
+ keys.length !== expected.length ||
37
+ !keys.every((key) => expected.includes(key))
38
+ ) {
39
+ throw new Error(`${label} has unknown fields`);
40
+ }
41
+ }
42
+
43
+ function revision(value: unknown, label: string): number {
44
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
45
+ throw new Error(`${label} is invalid`);
46
+ }
47
+ return value as number;
48
+ }
49
+
50
+ function boundedString(value: unknown, label: string, maximum: number): string {
51
+ if (
52
+ typeof value !== "string" ||
53
+ value.length === 0 ||
54
+ value.length > maximum
55
+ ) {
56
+ throw new Error(`${label} is invalid`);
57
+ }
58
+ return value;
59
+ }
60
+
61
+ function isoTimestamp(value: unknown, label: string): string {
62
+ const timestamp = boundedString(value, label, 64);
63
+ if (
64
+ !Number.isFinite(Date.parse(timestamp)) ||
65
+ new Date(timestamp).toISOString() !== timestamp
66
+ ) {
67
+ throw new Error(`${label} is invalid`);
68
+ }
69
+ return timestamp;
70
+ }
71
+
72
+ export function decodeDeploymentPolicyV1(input: unknown): DeploymentPolicyV1 {
73
+ const policy = record(input, "deployment policy");
74
+ exactKeys(
75
+ policy,
76
+ ["schemaVersion", "revision", "signups", "updatedAt", "updatedBy"],
77
+ "deployment policy",
78
+ );
79
+ if (policy.schemaVersion !== 1) {
80
+ throw new Error("deployment policy.schemaVersion is invalid");
81
+ }
82
+ const signups = record(policy.signups, "deployment policy.signups");
83
+ exactKeys(signups, ["open"], "deployment policy.signups");
84
+ if (typeof signups.open !== "boolean") {
85
+ throw new Error("deployment policy.signups.open is invalid");
86
+ }
87
+ return {
88
+ schemaVersion: 1,
89
+ revision: revision(policy.revision, "deployment policy.revision"),
90
+ signups: { open: signups.open },
91
+ updatedAt: isoTimestamp(policy.updatedAt, "deployment policy.updatedAt"),
92
+ updatedBy: boundedString(
93
+ policy.updatedBy,
94
+ "deployment policy.updatedBy",
95
+ 512,
96
+ ),
97
+ };
98
+ }
99
+
100
+ export function decodeSetSignupsCommandV1(input: unknown): SetSignupsCommandV1 {
101
+ const command = record(input, "deployment signup command");
102
+ exactKeys(
103
+ command,
104
+ ["schemaVersion", "type", "open", "revision"],
105
+ "deployment signup command",
106
+ );
107
+ if (
108
+ command.schemaVersion !== 1 ||
109
+ command.type !== "deployment/set-signups" ||
110
+ typeof command.open !== "boolean"
111
+ ) {
112
+ throw new Error("deployment signup command is invalid");
113
+ }
114
+ return {
115
+ schemaVersion: 1,
116
+ type: "deployment/set-signups",
117
+ open: command.open,
118
+ revision: revision(command.revision, "deployment signup command.revision"),
119
+ };
120
+ }
121
+
122
+ export function decodeDeploymentPolicyReadRequestV1(input: unknown): {
123
+ schemaVersion: 1;
124
+ } {
125
+ const request = record(input, "deployment policy read request");
126
+ exactKeys(request, ["schemaVersion"], "deployment policy read request");
127
+ if (request.schemaVersion !== 1) {
128
+ throw new Error("deployment policy read request.schemaVersion is invalid");
129
+ }
130
+ return { schemaVersion: 1 };
131
+ }
132
+
133
+ export function decodeSetSignupsRequestV1(input: unknown): SetSignupsRequestV1 {
134
+ const request = record(input, "deployment signup request");
135
+ exactKeys(
136
+ request,
137
+ ["schemaVersion", "command", "updatedBy"],
138
+ "deployment signup request",
139
+ );
140
+ if (request.schemaVersion !== 1) {
141
+ throw new Error("deployment signup request.schemaVersion is invalid");
142
+ }
143
+ return {
144
+ schemaVersion: 1,
145
+ command: decodeSetSignupsCommandV1(request.command),
146
+ updatedBy: boundedString(
147
+ request.updatedBy,
148
+ "deployment signup request.updatedBy",
149
+ 512,
150
+ ),
151
+ };
152
+ }
153
+
154
+ export class DeploymentPolicyConflictError extends Error {
155
+ readonly currentRevision: number;
156
+
157
+ constructor(currentRevision: number) {
158
+ super(`deployment policy revision is ${currentRevision}`);
159
+ this.name = "DeploymentPolicyConflictError";
160
+ this.currentRevision = currentRevision;
161
+ }
162
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "resolveJsonModule": true,
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
12
+ "types": ["bun"]
13
+ },
14
+ "include": ["src/**/*.ts", "src/**/*.vue"]
15
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # @frockbot/plugin-admin
2
-
3
- Placeholder reserving this name. See https://github.com/timoconnellaus/frockbot.