@shivaedev/admin 0.0.0 → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ShivaeDev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # @shivaedev/admin
2
+
3
+ Admin tooling starts with an explicit list of pages and the operations each page needs. This package collects native Effect contracts behind the administrator role, so every page operation shares the same boundary.
4
+
5
+ ## Declare the tools operators can use
6
+
7
+ ```ts
8
+ const Jobs = adminPage({
9
+ id: "jobs",
10
+ route: { path: "/admin/jobs", title: "Jobs" },
11
+ contract: contract("admin.jobs", { queries: [Failed] }),
12
+ });
13
+ const Admin = adminRegistry(Jobs);
14
+ ```
15
+
16
+ The page exposes `admin.jobs.failed` and the generated `admin.jobs.$changes` subscription. Both require `AuthenticatedAdmin`. A query-only contract has no command; an owner without the `admin` role remains forbidden.
17
+
18
+ ## Use native contracts and Layers
19
+
20
+ Install `@shivaedev/admin` with `effect`. Declare each page's queries, commands and streams with `@shivaedev/effect-contract`, then pass its native contract to `adminPage`. The stable id identifies the page at server registration; route metadata belongs to the application's view. Author RPC namespaces in the contract name, such as `admin.jobs`.
21
+
22
+ ```ts
23
+ import { Schema } from "effect";
24
+ import { adminPage } from "@shivaedev/admin/page.ts";
25
+ import { adminRegistry } from "@shivaedev/admin/registry.ts";
26
+ import { contract } from "@shivaedev/effect-contract/contract.ts";
27
+ import { query } from "@shivaedev/effect-contract/operation.ts";
28
+
29
+ const Failed = query("failed", {
30
+ reads: () => [],
31
+ success: Schema.Array(Schema.String),
32
+ });
33
+ const Jobs = adminPage({
34
+ id: "jobs",
35
+ route: { path: "/admin/jobs", title: "Jobs" },
36
+ contract: contract("admin.jobs", { queries: [Failed] }),
37
+ });
38
+ const Admin = adminRegistry(Jobs);
39
+ ```
40
+
41
+ `Jobs.contract` keeps the native `.declaration` and `.application.toLayer` APIs. `adminRegistry(...pages)` returns `pages` and a native `group`. Duplicate page ids, route paths and RPC tags, including generated subscriptions, fail when the registry is created.
42
+
43
+ Register server handler Layers by page id:
44
+
45
+ ```ts
46
+ import { Effect, Layer } from "effect";
47
+ import { registerAdmin } from "@shivaedev/admin/server/registerAdmin.ts";
48
+ import { authenticatedAdminLayer } from "@shivaedev/platform/server/rpc-server/roles.ts";
49
+
50
+ const registration = registerAdmin(Admin, {
51
+ jobs: Jobs.contract.application.toLayer({
52
+ "admin.jobs.failed": () => Effect.succeed(["mail-42"]),
53
+ }),
54
+ });
55
+ const handlers = registration.handlers.pipe(
56
+ Layer.provideMerge(authenticatedAdminLayer(sessions)),
57
+ );
58
+ ```
59
+
60
+ Pass `registration.group` and `handlers` to the existing application's RPC host on `/rpc`, merging them with its other groups and Layers as needed. The Platform host supplies generated change handlers; registration requires the page's application handlers. Handler Layer errors and service requirements remain inferred. Missing or extra page ids fail at registration; TypeScript also checks each Layer provides the handlers for its page.
61
+
62
+ Page declarations and the registry are browser-safe. Server registration lives under `/server`; views belong in the application. This package ships no UI components and adds no transport. The package's stories prove role enforcement through native in-process RPC, with an already resolved session as a precondition.
package/dist/page.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type { Rpc, RpcGroup } from "effect/rpc";
2
+ import type { Contract } from "@shivaedev/effect-contract/contract.ts";
3
+ import type { CommandShape, QueryShape, StreamShape } from "@shivaedev/effect-contract/operation.ts";
4
+ import { AuthenticatedAdmin } from "@shivaedev/platform/rpc/middleware.ts";
5
+ export interface AdminRoute {
6
+ readonly path: string;
7
+ readonly title: string;
8
+ }
9
+ declare const AdminPageTypeId: unique symbol;
10
+ export interface AdminPage {
11
+ readonly contract: RpcGroup.Any & {
12
+ readonly application: RpcGroup.Any;
13
+ readonly requests: ReadonlyMap<string, Rpc.Any>;
14
+ };
15
+ readonly id: string;
16
+ readonly route: AdminRoute;
17
+ readonly [AdminPageTypeId]: true;
18
+ }
19
+ export declare function adminPage<const TId extends string, const TName extends string, const TQueries extends readonly QueryShape[], const TCommands extends readonly CommandShape[], TRpcs extends Rpc.Any, const TStreams extends readonly StreamShape[]>(page: {
20
+ readonly id: TId;
21
+ readonly route: AdminRoute;
22
+ readonly contract: Contract<TName, TQueries, TCommands, TRpcs, TStreams>;
23
+ }): {
24
+ id: TId;
25
+ route: AdminRoute;
26
+ [AdminPageTypeId]: true;
27
+ contract: Contract<TName, TQueries, TCommands, Rpc.AddMiddleware<TRpcs, typeof AuthenticatedAdmin>, TStreams, undefined>;
28
+ };
29
+ export {};
30
+ //# sourceMappingURL=page.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page.d.ts","sourceRoot":"","sources":["../src/page.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wCAAwC,CAAC;AACvE,OAAO,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,yCAAyC,CAAC;AACrG,OAAO,EAAE,kBAAkB,EAAE,MAAM,uCAAuC,CAAC;AAE3E,MAAM,WAAW,UAAU;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACvB;AAED,QAAA,MAAM,eAAe,eAAsC,CAAC;AAE5D,MAAM,WAAW,SAAS;IACzB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC,GAAG,GAAG;QACjC,QAAQ,CAAC,WAAW,EAAE,QAAQ,CAAC,GAAG,CAAC;QACnC,QAAQ,CAAC,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,CAAC,CAAC;KAChD,CAAC;IACF,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;IAC3B,QAAQ,CAAC,CAAC,eAAe,CAAC,EAAE,IAAI,CAAC;CACjC;AAED,wBAAgB,SAAS,CACxB,KAAK,CAAC,GAAG,SAAS,MAAM,EACxB,KAAK,CAAC,KAAK,SAAS,MAAM,EAC1B,KAAK,CAAC,QAAQ,SAAS,SAAS,UAAU,EAAE,EAC5C,KAAK,CAAC,SAAS,SAAS,SAAS,YAAY,EAAE,EAC/C,KAAK,SAAS,GAAG,CAAC,GAAG,EACrB,KAAK,CAAC,QAAQ,SAAS,SAAS,WAAW,EAAE,EAC5C,IAAI,EAAE;IAAE,QAAQ,CAAC,EAAE,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,KAAK,EAAE,QAAQ,CAAC,CAAA;CAAE;QAA3G,GAAG;WAAkB,UAAU;;;EAErD"}
package/dist/page.js ADDED
@@ -0,0 +1,6 @@
1
+ import { AuthenticatedAdmin } from "@shivaedev/platform/rpc/middleware.ts";
2
+ const AdminPageTypeId = Symbol.for("@shivaedev/admin/Page");
3
+ export function adminPage(page) {
4
+ return { ...page, [AdminPageTypeId]: true, contract: page.contract.middleware(AuthenticatedAdmin) };
5
+ }
6
+ //# sourceMappingURL=page.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page.js","sourceRoot":"","sources":["../src/page.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,kBAAkB,EAAE,MAAM,uCAAuC,CAAC;AAO3E,MAAM,eAAe,GAAG,MAAM,CAAC,GAAG,CAAC,uBAAuB,CAAC,CAAC;AAY5D,MAAM,UAAU,SAAS,CAOvB,IAAgI;IACjI,OAAO,EAAE,GAAG,IAAI,EAAE,CAAC,eAAe,CAAC,EAAE,IAAa,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,kBAAkB,CAAC,EAAE,CAAC;AAC9G,CAAC"}
@@ -0,0 +1,8 @@
1
+ import { RpcGroup } from "effect/rpc";
2
+ import type { AdminPage } from "./page.ts";
3
+ export interface AdminRegistry<TPages extends readonly AdminPage[]> {
4
+ readonly group: RpcGroup.RpcGroup<RpcGroup.Rpcs<TPages[number]["contract"]>>;
5
+ readonly pages: TPages;
6
+ }
7
+ export declare function adminRegistry<const TPages extends readonly AdminPage[]>(...pages: TPages): AdminRegistry<TPages>;
8
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAE3C,MAAM,WAAW,aAAa,CAAC,MAAM,SAAS,SAAS,SAAS,EAAE;IACjE,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;IAC7E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,aAAa,CAAC,KAAK,CAAC,MAAM,SAAS,SAAS,SAAS,EAAE,EAAE,GAAG,KAAK,EAAE,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAkBhH"}
@@ -0,0 +1,21 @@
1
+ import { RpcGroup } from "effect/rpc";
2
+ export function adminRegistry(...pages) {
3
+ const ids = new Set();
4
+ const paths = new Set();
5
+ const tags = new Set();
6
+ function unique(seen, value, kind) {
7
+ if (seen.has(value)) {
8
+ throw new Error(`Duplicate admin ${kind}: ${value}`);
9
+ }
10
+ seen.add(value);
11
+ }
12
+ for (const page of pages) {
13
+ unique(ids, page.id, "page id");
14
+ unique(paths, page.route.path, "path");
15
+ for (const tag of page.contract.requests.keys()) {
16
+ unique(tags, tag, "RPC tag");
17
+ }
18
+ }
19
+ return { group: RpcGroup.make().merge(...pages.map((page) => page.contract)), pages };
20
+ }
21
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAQtC,MAAM,UAAU,aAAa,CAA4C,GAAG,KAAa;IACxF,MAAM,GAAG,GAAG,IAAI,GAAG,EAAU,CAAC;IAC9B,MAAM,KAAK,GAAG,IAAI,GAAG,EAAU,CAAC;IAChC,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,SAAS,MAAM,CAAC,IAAiB,EAAE,KAAa,EAAE,IAAY;QAC7D,IAAI,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YACrB,MAAM,IAAI,KAAK,CAAC,mBAAmB,IAAI,KAAK,KAAK,EAAE,CAAC,CAAC;QACtD,CAAC;QACD,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IACjB,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QAC1B,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,EAAE,EAAE,SAAS,CAAC,CAAC;QAChC,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QACvC,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE,EAAE,CAAC;YACjD,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE,SAAS,CAAC,CAAC;QAC9B,CAAC;IACF,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC;AACvF,CAAC"}
@@ -0,0 +1,14 @@
1
+ import { Layer } from "effect";
2
+ import type { Rpc, RpcGroup } from "effect/rpc";
3
+ import type { AdminPage } from "#page.ts";
4
+ import type { AdminRegistry } from "#registry.ts";
5
+ type HandlerLayers<TPages extends readonly AdminPage[]> = {
6
+ readonly [TPage in TPages[number] as TPage["id"]]: Layer.Layer<Rpc.ToHandler<RpcGroup.Rpcs<TPage["contract"]["application"]>>, unknown, unknown>;
7
+ };
8
+ type RegisteredLayers<THandlers> = Extract<THandlers[keyof THandlers], Layer.Any>;
9
+ export declare function registerAdmin<const TPages extends readonly AdminPage[], THandlers extends HandlerLayers<TPages>>(registry: AdminRegistry<TPages>, handlers: THandlers & Record<Exclude<keyof THandlers, TPages[number]["id"]>, never>): {
10
+ readonly group: AdminRegistry<TPages>["group"];
11
+ readonly handlers: Layer.Layer<Layer.Success<RegisteredLayers<THandlers>>, Layer.Error<RegisteredLayers<THandlers>>, Layer.Services<RegisteredLayers<THandlers>>>;
12
+ };
13
+ export {};
14
+ //# sourceMappingURL=registerAdmin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registerAdmin.d.ts","sourceRoot":"","sources":["../../src/server/registerAdmin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAC/B,OAAO,KAAK,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAC1C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,KAAK,aAAa,CAAC,MAAM,SAAS,SAAS,SAAS,EAAE,IAAI;IACzD,QAAQ,EAAE,KAAK,IAAI,MAAM,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC;CAChJ,CAAC;AAEF,KAAK,gBAAgB,CAAC,SAAS,IAAI,OAAO,CAAC,SAAS,CAAC,MAAM,SAAS,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC;AAElF,wBAAgB,aAAa,CAAC,KAAK,CAAC,MAAM,SAAS,SAAS,SAAS,EAAE,EAAE,SAAS,SAAS,aAAa,CAAC,MAAM,CAAC,EAC/G,QAAQ,EAAE,aAAa,CAAC,MAAM,CAAC,EAC/B,QAAQ,EAAE,SAAS,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,SAAS,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,EAAE,KAAK,CAAC,GACjF;IACF,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,CAAC;IAC/C,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAC,KAAK,CAC7B,KAAK,CAAC,OAAO,CAAC,gBAAgB,CAAC,SAAS,CAAC,CAAC,EAC1C,KAAK,CAAC,KAAK,CAAC,gBAAgB,CAAC,SAAS,CAAC,CAAC,EACxC,KAAK,CAAC,QAAQ,CAAC,gBAAgB,CAAC,SAAS,CAAC,CAAC,CAC3C,CAAC;CACF,CAAC"}
@@ -0,0 +1,16 @@
1
+ import { Layer } from "effect";
2
+ export function registerAdmin(registry, handlers) {
3
+ const ids = new Set(registry.pages.map((page) => page.id));
4
+ for (const id of ids) {
5
+ if (!Object.hasOwn(handlers, id)) {
6
+ throw new Error(`Missing admin handlers: ${id}`);
7
+ }
8
+ }
9
+ for (const id of Object.keys(handlers)) {
10
+ if (!ids.has(id)) {
11
+ throw new Error(`Unregistered admin handlers: ${id}`);
12
+ }
13
+ }
14
+ return { group: registry.group, handlers: Layer.mergeAll(Layer.empty, ...Object.values(handlers)) };
15
+ }
16
+ //# sourceMappingURL=registerAdmin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registerAdmin.js","sourceRoot":"","sources":["../../src/server/registerAdmin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AAsB/B,MAAM,UAAU,aAAa,CAC5B,QAA6C,EAC7C,QAAwE;IAExE,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;IAC3D,KAAK,MAAM,EAAE,IAAI,GAAG,EAAE,CAAC;QACtB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,QAAQ,EAAE,EAAE,CAAC,EAAE,CAAC;YAClC,MAAM,IAAI,KAAK,CAAC,2BAA2B,EAAE,EAAE,CAAC,CAAC;QAClD,CAAC;IACF,CAAC;IACD,KAAK,MAAM,EAAE,IAAI,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;QACxC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC;YAClB,MAAM,IAAI,KAAK,CAAC,gCAAgC,EAAE,EAAE,CAAC,CAAC;QACvD,CAAC;IACF,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,QAAQ,CAAC,KAAK,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC,KAAK,EAAE,GAAG,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC;AACrG,CAAC"}
package/package.json CHANGED
@@ -1,11 +1,69 @@
1
1
  {
2
2
  "name": "@shivaedev/admin",
3
- "version": "0.0.0",
4
- "description": "Placeholder for @shivaedev/admin. The first real release follows.",
5
- "license": "MIT",
3
+ "version": "0.1.0",
4
+ "description": "Explicit admin page contracts and a protected native Effect RPC registry",
5
+ "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/admin#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/ShivaeDev/platform/issues"
8
+ },
6
9
  "repository": {
7
10
  "type": "git",
8
11
  "url": "git+https://github.com/ShivaeDev/platform.git",
9
12
  "directory": "packages/admin"
13
+ },
14
+ "license": "MIT",
15
+ "sideEffects": false,
16
+ "type": "module",
17
+ "imports": {
18
+ "#test/*": "./src/test-support/*",
19
+ "#*.ts": {
20
+ "source": "./src/*.ts",
21
+ "types": "./dist/*.d.ts",
22
+ "default": "./dist/*.js"
23
+ }
24
+ },
25
+ "exports": {
26
+ "./*.ts": {
27
+ "source": "./src/*.ts",
28
+ "types": "./dist/*.d.ts",
29
+ "default": "./dist/*.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "src",
36
+ "!src/**/*.test.ts",
37
+ "!src/**/*.spec.ts",
38
+ "!src/**/test-support",
39
+ "README.md"
40
+ ],
41
+ "dependencies": {
42
+ "@shivaedev/effect-contract": "0.12.0",
43
+ "@shivaedev/platform": "0.28.0"
44
+ },
45
+ "devDependencies": {
46
+ "@shivaedev/quality": "0.38.0",
47
+ "@shivaedev/test-story": "0.19.0",
48
+ "@types/node": "24.19.2",
49
+ "effect": "4.0.5",
50
+ "typescript": "7.0.2",
51
+ "vitest": "5.0.5"
52
+ },
53
+ "peerDependencies": {
54
+ "effect": "4.0.5"
55
+ },
56
+ "engines": {
57
+ "node": ">=24"
58
+ },
59
+ "publishConfig": {
60
+ "access": "public",
61
+ "provenance": true
62
+ },
63
+ "scripts": {
64
+ "build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc --project tsconfig.build.json",
65
+ "ready": "pnpm typecheck && pnpm test && pnpm build",
66
+ "test": "vitest run",
67
+ "typecheck": "tsc --noEmit"
10
68
  }
11
69
  }
package/src/page.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { Rpc, RpcGroup } from "effect/rpc";
2
+ import type { Contract } from "@shivaedev/effect-contract/contract.ts";
3
+ import type { CommandShape, QueryShape, StreamShape } from "@shivaedev/effect-contract/operation.ts";
4
+ import { AuthenticatedAdmin } from "@shivaedev/platform/rpc/middleware.ts";
5
+
6
+ export interface AdminRoute {
7
+ readonly path: string;
8
+ readonly title: string;
9
+ }
10
+
11
+ const AdminPageTypeId = Symbol.for("@shivaedev/admin/Page");
12
+
13
+ export interface AdminPage {
14
+ readonly contract: RpcGroup.Any & {
15
+ readonly application: RpcGroup.Any;
16
+ readonly requests: ReadonlyMap<string, Rpc.Any>;
17
+ };
18
+ readonly id: string;
19
+ readonly route: AdminRoute;
20
+ readonly [AdminPageTypeId]: true;
21
+ }
22
+
23
+ export function adminPage<
24
+ const TId extends string,
25
+ const TName extends string,
26
+ const TQueries extends readonly QueryShape[],
27
+ const TCommands extends readonly CommandShape[],
28
+ TRpcs extends Rpc.Any,
29
+ const TStreams extends readonly StreamShape[],
30
+ >(page: { readonly id: TId; readonly route: AdminRoute; readonly contract: Contract<TName, TQueries, TCommands, TRpcs, TStreams> }) {
31
+ return { ...page, [AdminPageTypeId]: true as const, contract: page.contract.middleware(AuthenticatedAdmin) };
32
+ }
@@ -0,0 +1,27 @@
1
+ import { RpcGroup } from "effect/rpc";
2
+ import type { AdminPage } from "./page.ts";
3
+
4
+ export interface AdminRegistry<TPages extends readonly AdminPage[]> {
5
+ readonly group: RpcGroup.RpcGroup<RpcGroup.Rpcs<TPages[number]["contract"]>>;
6
+ readonly pages: TPages;
7
+ }
8
+
9
+ export function adminRegistry<const TPages extends readonly AdminPage[]>(...pages: TPages): AdminRegistry<TPages> {
10
+ const ids = new Set<string>();
11
+ const paths = new Set<string>();
12
+ const tags = new Set<string>();
13
+ function unique(seen: Set<string>, value: string, kind: string) {
14
+ if (seen.has(value)) {
15
+ throw new Error(`Duplicate admin ${kind}: ${value}`);
16
+ }
17
+ seen.add(value);
18
+ }
19
+ for (const page of pages) {
20
+ unique(ids, page.id, "page id");
21
+ unique(paths, page.route.path, "path");
22
+ for (const tag of page.contract.requests.keys()) {
23
+ unique(tags, tag, "RPC tag");
24
+ }
25
+ }
26
+ return { group: RpcGroup.make().merge(...pages.map((page) => page.contract)), pages };
27
+ }
@@ -0,0 +1,39 @@
1
+ import { Layer } from "effect";
2
+ import type { Rpc, RpcGroup } from "effect/rpc";
3
+ import type { AdminPage } from "#page.ts";
4
+ import type { AdminRegistry } from "#registry.ts";
5
+
6
+ type HandlerLayers<TPages extends readonly AdminPage[]> = {
7
+ readonly [TPage in TPages[number] as TPage["id"]]: Layer.Layer<Rpc.ToHandler<RpcGroup.Rpcs<TPage["contract"]["application"]>>, unknown, unknown>;
8
+ };
9
+
10
+ type RegisteredLayers<THandlers> = Extract<THandlers[keyof THandlers], Layer.Any>;
11
+
12
+ export function registerAdmin<const TPages extends readonly AdminPage[], THandlers extends HandlerLayers<TPages>>(
13
+ registry: AdminRegistry<TPages>,
14
+ handlers: THandlers & Record<Exclude<keyof THandlers, TPages[number]["id"]>, never>,
15
+ ): {
16
+ readonly group: AdminRegistry<TPages>["group"];
17
+ readonly handlers: Layer.Layer<
18
+ Layer.Success<RegisteredLayers<THandlers>>,
19
+ Layer.Error<RegisteredLayers<THandlers>>,
20
+ Layer.Services<RegisteredLayers<THandlers>>
21
+ >;
22
+ };
23
+ export function registerAdmin(
24
+ registry: AdminRegistry<readonly AdminPage[]>,
25
+ handlers: Readonly<Record<string, Layer.Layer<never, unknown, unknown>>>,
26
+ ) {
27
+ const ids = new Set(registry.pages.map((page) => page.id));
28
+ for (const id of ids) {
29
+ if (!Object.hasOwn(handlers, id)) {
30
+ throw new Error(`Missing admin handlers: ${id}`);
31
+ }
32
+ }
33
+ for (const id of Object.keys(handlers)) {
34
+ if (!ids.has(id)) {
35
+ throw new Error(`Unregistered admin handlers: ${id}`);
36
+ }
37
+ }
38
+ return { group: registry.group, handlers: Layer.mergeAll(Layer.empty, ...Object.values(handlers)) };
39
+ }