@sferarc/aula-pulumi 0.0.0-stage → 0.0.2

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 Sferarc
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 CHANGED
@@ -1,3 +1,30 @@
1
- # Temporary Holding Version
1
+ # @sferarc/aula-pulumi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pulumi dynamic provider for an Aula school, generated by `@sferarc/iac-gen` from `apps/aula/openapi/v1.json`. Run `pnpm generate` after the OpenAPI document changes; the test fails on drift.
4
+
5
+ Set the per business API token as the secret `aula:token`, or call `configure({ token })`. The token is never written to state in plain text.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @sferarc/aula-pulumi @pulumi/pulumi
11
+ ```
12
+
13
+ `@pulumi/pulumi` is a peer dependency.
14
+
15
+ ## Example
16
+
17
+ Create an API token with the `site:write` scope in the school's settings and store it with `pulumi config set --secret aula:token aula_...`. Use `aula:baseUrl` for another host; plain HTTP is refused except on localhost.
18
+
19
+ ```ts
20
+ import { SiteConfig } from "@sferarc/aula-pulumi";
21
+
22
+ export const site = new SiteConfig("site", {
23
+ version: 1,
24
+ identity: { name: "Example Music School", tagline: "Lessons for every age" },
25
+ design: { accentColor: "#1d4ed8" },
26
+ locale: { defaultLocale: "en", timeZone: "Europe/Madrid", currency: "EUR" },
27
+ });
28
+ ```
29
+
30
+ `pulumi up` creates or updates the school's site config; a second run plans no changes. The resource is never deleted with the stack.
@@ -0,0 +1 @@
1
+ export * from "./siteConfig.gen.js";
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ // Code generated by @sferarc/iac-gen. DO NOT EDIT.
2
+ export * from "./siteConfig.gen.js";
package/dist/main.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./index.js";
2
+ export { configure } from "./runtime.js";
package/dist/main.js ADDED
@@ -0,0 +1,2 @@
1
+ export * from "./index.js";
2
+ export { configure } from "./runtime.js";
@@ -0,0 +1,12 @@
1
+ import * as pulumi from "@pulumi/pulumi";
2
+ /** Set the token once in a Pulumi program; the `aula:token` secret config or AULA_TOKEN work without it. */
3
+ export declare function configure(args: {
4
+ token: pulumi.Input<string>;
5
+ baseUrl?: string;
6
+ }): void;
7
+ /** Called by each generated provider's `configure(req)` with the stack config. */
8
+ export declare function setProviderConfig(config: Pick<pulumi.dynamic.Config, "get">): void;
9
+ /** Drop `undefined` values, which Pulumi's struct encoding rejects. */
10
+ export declare function stripUndefined<T extends Record<string, unknown>>(obj: T): T;
11
+ /** Call the management API; a 404 or an empty body returns undefined, any other failure throws without the token or body secrets. */
12
+ export declare function callApi(method: string, path: string, body?: unknown): Promise<unknown>;
@@ -0,0 +1,71 @@
1
+ import * as pulumi from "@pulumi/pulumi";
2
+ const DEFAULT_BASE_URL = "https://aula.sferarc.com";
3
+ const REQUEST_TIMEOUT_MS = 15_000;
4
+ // A secret Output, so a provider closure that captures it is encrypted in state.
5
+ let globalConfig;
6
+ /** Set the token once in a Pulumi program; the `aula:token` secret config or AULA_TOKEN work without it. */
7
+ export function configure(args) {
8
+ globalConfig = pulumi.secret({ token: args.token, baseUrl: args.baseUrl ?? DEFAULT_BASE_URL });
9
+ }
10
+ // An object, not a `let`: the serializer copies primitives per function but shares a captured object.
11
+ const providerState = {};
12
+ /** Called by each generated provider's `configure(req)` with the stack config. */
13
+ export function setProviderConfig(config) {
14
+ providerState.config = config;
15
+ }
16
+ function getConfig() {
17
+ if (globalConfig)
18
+ return globalConfig.get();
19
+ const token = providerState.config?.get("aula:token") ?? process.env.AULA_TOKEN;
20
+ if (!token) {
21
+ throw new Error("Aula API token is required. Set aula:token as a secret, call configure(), or set AULA_TOKEN.");
22
+ }
23
+ const baseUrl = providerState.config?.get("aula:baseUrl") ?? process.env.AULA_API_URL ?? DEFAULT_BASE_URL;
24
+ return { token, baseUrl };
25
+ }
26
+ /** Drop `undefined` values, which Pulumi's struct encoding rejects. */
27
+ export function stripUndefined(obj) {
28
+ const result = {};
29
+ for (const [key, value] of Object.entries(obj)) {
30
+ if (value === undefined)
31
+ continue;
32
+ result[key] =
33
+ value !== null && typeof value === "object" && !Array.isArray(value)
34
+ ? stripUndefined(value)
35
+ : value;
36
+ }
37
+ return result;
38
+ }
39
+ /** Call the management API; a 404 or an empty body returns undefined, any other failure throws without the token or body secrets. */
40
+ export async function callApi(method, path, body) {
41
+ const { token, baseUrl } = getConfig();
42
+ const url = new URL(path, baseUrl);
43
+ if (url.protocol !== "https:" && url.hostname !== "localhost") {
44
+ throw new Error(`Aula: refusing to send the token over ${url.protocol}`);
45
+ }
46
+ const response = await fetch(url, {
47
+ method,
48
+ headers: {
49
+ Authorization: `Bearer ${token}`,
50
+ Accept: "application/json",
51
+ ...(body === undefined ? {} : { "Content-Type": "application/json" }),
52
+ },
53
+ body: body === undefined ? undefined : JSON.stringify(body),
54
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
55
+ });
56
+ if (response.status === 404 && method === "GET")
57
+ return undefined;
58
+ const text = await response.text();
59
+ if (!response.ok) {
60
+ let detail = "";
61
+ try {
62
+ const problem = JSON.parse(text);
63
+ detail = [problem.title, problem.detail].filter((v) => typeof v === "string").join(": ");
64
+ }
65
+ catch {
66
+ // Not a problem document; the status alone is reported.
67
+ }
68
+ throw new Error(`Aula ${method} ${url.pathname} failed (${response.status})${detail ? `: ${detail}` : ""}`);
69
+ }
70
+ return text ? JSON.parse(text) : undefined;
71
+ }
@@ -0,0 +1,46 @@
1
+ import * as pulumi from "@pulumi/pulumi";
2
+ export interface SiteConfigArgs {
3
+ version: pulumi.Input<number>;
4
+ identity?: pulumi.Input<Record<string, unknown>>;
5
+ design?: pulumi.Input<Record<string, unknown>>;
6
+ locale?: pulumi.Input<Record<string, unknown>>;
7
+ classPolicy?: pulumi.Input<Record<string, unknown>>;
8
+ fees?: pulumi.Input<Record<string, unknown>>;
9
+ /** Questions and answers in page order; each question and answer takes a string or a map from locale to string. */
10
+ faq?: pulumi.Input<Array<Record<string, unknown>>>;
11
+ /** The home's blocks in page order; hero, teachers, about and faq read their text from identity and faq. Headings, rich text bodies, call to action text and labels, and image alt text and captions take a string or a map from locale to string. An image's key is the storage key of an image this school uploaded, never a URL. */
12
+ sections?: pulumi.Input<Array<unknown>>;
13
+ /** Header and footer links in order; sign in, the locale switcher and Aula's own links always show besides them. */
14
+ navigation?: pulumi.Input<Record<string, unknown>>;
15
+ /** The school's own pages in order, each served at its slug with blocks as in sections. Titles and descriptions take a string or a map from locale to string. A slug is unique and never one of Aula's own paths. */
16
+ pages?: pulumi.Input<Array<Record<string, unknown>>>;
17
+ /** The school's own email text: a footer of address, contact and policy lines, and per email kind and locale a subject, heading, body and call to action label with the placeholders each kind allows. Plain text; a body link is [label](target). Left out, an email keeps Aula's text. The document replaces the stored text as a whole. */
18
+ emails?: pulumi.Input<Record<string, unknown>>;
19
+ /** The school's extra branded sites in order, each with its own name, design and the part of the offer it shows: lesson types and classes by id, and lesson types by subject. The list replaces the stored one as a whole: a key left out archives that site and naming it again restores it. A design token left out is Aula's default. */
20
+ sites?: pulumi.Input<Array<Record<string, unknown>>>;
21
+ }
22
+ export declare const siteConfigProvider: pulumi.dynamic.ResourceProvider;
23
+ /** The school's whole site document. One per school: it exists with the school, so it is read and replaced, never created or deleted. */
24
+ export declare class SiteConfig extends pulumi.dynamic.Resource {
25
+ readonly version: pulumi.Output<number>;
26
+ readonly identity: pulumi.Output<Record<string, unknown>>;
27
+ readonly design: pulumi.Output<Record<string, unknown>>;
28
+ readonly locale: pulumi.Output<Record<string, unknown>>;
29
+ readonly classPolicy: pulumi.Output<Record<string, unknown>>;
30
+ readonly fees: pulumi.Output<Record<string, unknown>>;
31
+ /** Questions and answers in page order; each question and answer takes a string or a map from locale to string. */
32
+ readonly faq: pulumi.Output<Array<Record<string, unknown>>>;
33
+ /** The home's blocks in page order; hero, teachers, about and faq read their text from identity and faq. Headings, rich text bodies, call to action text and labels, and image alt text and captions take a string or a map from locale to string. An image's key is the storage key of an image this school uploaded, never a URL. */
34
+ readonly sections: pulumi.Output<Array<unknown>>;
35
+ /** Header and footer links in order; sign in, the locale switcher and Aula's own links always show besides them. */
36
+ readonly navigation: pulumi.Output<Record<string, unknown>>;
37
+ /** The school's own pages in order, each served at its slug with blocks as in sections. Titles and descriptions take a string or a map from locale to string. A slug is unique and never one of Aula's own paths. */
38
+ readonly pages: pulumi.Output<Array<Record<string, unknown>>>;
39
+ /** The school's own email text: a footer of address, contact and policy lines, and per email kind and locale a subject, heading, body and call to action label with the placeholders each kind allows. Plain text; a body link is [label](target). Left out, an email keeps Aula's text. The document replaces the stored text as a whole. */
40
+ readonly emails: pulumi.Output<Record<string, unknown>>;
41
+ /** The school's extra branded sites in order, each with its own name, design and the part of the offer it shows: lesson types and classes by id, and lesson types by subject. The list replaces the stored one as a whole: a key left out archives that site and naming it again restores it. A design token left out is Aula's default. */
42
+ readonly sites: pulumi.Output<Array<Record<string, unknown>>>;
43
+ /** Exported for reference; an import never changes it. */
44
+ readonly domain: pulumi.Output<unknown>;
45
+ constructor(name: string, args: SiteConfigArgs, opts?: pulumi.CustomResourceOptions);
46
+ }
@@ -0,0 +1,46 @@
1
+ // Code generated by @sferarc/iac-gen. DO NOT EDIT.
2
+ import * as pulumi from "@pulumi/pulumi";
3
+ import { callApi, setProviderConfig, stripUndefined } from "./runtime.js";
4
+ const inputFields = ["version", "identity", "design", "locale", "classPolicy", "fees", "faq", "sections", "navigation", "pages", "emails", "sites"];
5
+ const writeOnlyFields = [];
6
+ function pick(source, keys) {
7
+ const picked = {};
8
+ for (const key of keys)
9
+ picked[key] = source[key];
10
+ return picked;
11
+ }
12
+ export const siteConfigProvider = {
13
+ async configure(req) {
14
+ setProviderConfig(req.config);
15
+ },
16
+ async create(inputs) {
17
+ const result = (await callApi("PUT", `/api/v1/site-config`, stripUndefined(pick(inputs, inputFields))));
18
+ const id = "SiteConfig";
19
+ return { id, outs: stripUndefined({ ...inputs, ...result }) };
20
+ },
21
+ async read(id, props) {
22
+ const current = (await callApi("GET", `/api/v1/site-config`));
23
+ if (!current)
24
+ return {};
25
+ const kept = pick(props, writeOnlyFields);
26
+ return { id, props: stripUndefined({ ...props, ...current, ...kept }) };
27
+ },
28
+ async update(id, _olds, news) {
29
+ const result = (await callApi("PUT", `/api/v1/site-config`, stripUndefined(pick(news, inputFields))));
30
+ return { outs: stripUndefined({ ...news, ...result }) };
31
+ },
32
+ async delete(id, props) {
33
+ void id;
34
+ void props;
35
+ },
36
+ async diff(_id, olds, news) {
37
+ const changes = inputFields.filter((key) => JSON.stringify(olds[key] ?? null) !== JSON.stringify(news[key] ?? null));
38
+ return { changes: changes.length > 0, deleteBeforeReplace: false };
39
+ },
40
+ };
41
+ /** The school's whole site document. One per school: it exists with the school, so it is read and replaced, never created or deleted. */
42
+ export class SiteConfig extends pulumi.dynamic.Resource {
43
+ constructor(name, args, opts) {
44
+ super(siteConfigProvider, name, { "domain": undefined, ...args }, { ...opts, additionalSecretOutputs: [] });
45
+ }
46
+ }
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@sferarc/aula-pulumi",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.2",
4
+ "description": "Pulumi provider that declares an Aula school as code, generated from the management API's OpenAPI document",
5
+ "keywords": [
6
+ "pulumi",
7
+ "aula",
8
+ "iac",
9
+ "infrastructure-as-code"
10
+ ],
11
+ "homepage": "https://github.com/sferarc/platform/tree/main/packages/aula-pulumi#readme",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/sferarc/platform.git",
15
+ "directory": "packages/aula-pulumi"
16
+ },
17
+ "type": "module",
18
+ "license": "MIT",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/main.d.ts",
22
+ "default": "./dist/main.js"
23
+ }
24
+ },
25
+ "peerDependencies": {
26
+ "@pulumi/pulumi": "^3.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "@pulumi/pulumi": "3.274.1",
30
+ "@sferarc/iac-gen": "0.2.4",
31
+ "@sferarc/tsconfig": "0.0.0",
32
+ "@types/node": "26.6.6",
33
+ "tsx": "4.23.15",
34
+ "typescript": "7.0.2",
35
+ "vitest": "5.1.0"
36
+ },
37
+ "main": "./dist/main.js",
38
+ "types": "./dist/main.d.ts",
39
+ "files": [
40
+ "dist",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "publishConfig": {
45
+ "access": "public",
46
+ "provenance": true
47
+ },
48
+ "scripts": {
49
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
50
+ "generate": "tsx scripts/generate.ts",
51
+ "tsc": "tsc --noEmit",
52
+ "test": "vitest run"
53
+ }
6
54
  }