@maravilla-labs/platform 0.1.36 → 0.1.39
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/dist/config.d.ts +192 -0
- package/dist/config.js +8 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +140 -1
- package/dist/index.js.map +1 -1
- package/dist/push.d.ts +36 -0
- package/dist/push.js +141 -0
- package/dist/push.js.map +1 -0
- package/package.json +9 -1
- package/src/config.ts +219 -0
- package/src/index.ts +1 -0
- package/src/push.ts +207 -0
- package/tsup.config.ts +1 -1
package/dist/push.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// src/push.ts
|
|
2
|
+
var DEFAULT_BASE_PATH = "/_platform/push";
|
|
3
|
+
var DEFAULT_SW_PATH = "/_platform/push/sw.js";
|
|
4
|
+
var VISITOR_STORAGE_KEY = "maravilla.push.visitorId";
|
|
5
|
+
function assertPushSupported() {
|
|
6
|
+
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) {
|
|
7
|
+
throw new Error("Web Push is not supported: serviceWorker is unavailable");
|
|
8
|
+
}
|
|
9
|
+
if (typeof window === "undefined" || !("PushManager" in window)) {
|
|
10
|
+
throw new Error("Web Push is not supported: PushManager is unavailable");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function base64UrlToArrayBuffer(input) {
|
|
14
|
+
const padding = "=".repeat((4 - input.length % 4) % 4);
|
|
15
|
+
const base64 = (input + padding).replace(/-/g, "+").replace(/_/g, "/");
|
|
16
|
+
const raw = atob(base64);
|
|
17
|
+
const buffer = new ArrayBuffer(raw.length);
|
|
18
|
+
const view = new Uint8Array(buffer);
|
|
19
|
+
for (let i = 0; i < raw.length; i++) {
|
|
20
|
+
view[i] = raw.charCodeAt(i);
|
|
21
|
+
}
|
|
22
|
+
return buffer;
|
|
23
|
+
}
|
|
24
|
+
function arrayBufferToBase64Url(buffer) {
|
|
25
|
+
if (!buffer) return void 0;
|
|
26
|
+
const bytes = new Uint8Array(buffer);
|
|
27
|
+
let binary = "";
|
|
28
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
29
|
+
binary += String.fromCharCode(bytes[i]);
|
|
30
|
+
}
|
|
31
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
32
|
+
}
|
|
33
|
+
function randomUuid() {
|
|
34
|
+
const c = typeof crypto !== "undefined" ? crypto : void 0;
|
|
35
|
+
if (c && typeof c.randomUUID === "function") {
|
|
36
|
+
return c.randomUUID();
|
|
37
|
+
}
|
|
38
|
+
const bytes = new Uint8Array(16);
|
|
39
|
+
if (c && typeof c.getRandomValues === "function") {
|
|
40
|
+
c.getRandomValues(bytes);
|
|
41
|
+
} else {
|
|
42
|
+
for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
43
|
+
}
|
|
44
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
45
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
46
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
47
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
48
|
+
}
|
|
49
|
+
function resolveVisitorId(userId, visitorId) {
|
|
50
|
+
if (visitorId) return visitorId;
|
|
51
|
+
if (userId) return null;
|
|
52
|
+
try {
|
|
53
|
+
const stored = window.localStorage.getItem(VISITOR_STORAGE_KEY);
|
|
54
|
+
if (stored) return stored;
|
|
55
|
+
const fresh = randomUuid();
|
|
56
|
+
window.localStorage.setItem(VISITOR_STORAGE_KEY, fresh);
|
|
57
|
+
return fresh;
|
|
58
|
+
} catch {
|
|
59
|
+
return randomUuid();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function fetchVapidPublicKey(basePath) {
|
|
63
|
+
const res = await fetch(`${basePath}/vapid-public-key`, {
|
|
64
|
+
method: "GET",
|
|
65
|
+
credentials: "same-origin",
|
|
66
|
+
headers: { Accept: "application/json" }
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
throw new Error(`Failed to fetch VAPID public key: ${res.status} ${res.statusText}`);
|
|
70
|
+
}
|
|
71
|
+
const body = await res.json();
|
|
72
|
+
if (!body || typeof body.publicKey !== "string" || body.publicKey.length === 0) {
|
|
73
|
+
throw new Error("VAPID public key response is missing `publicKey`");
|
|
74
|
+
}
|
|
75
|
+
return body.publicKey;
|
|
76
|
+
}
|
|
77
|
+
function extractKeys(sub) {
|
|
78
|
+
return {
|
|
79
|
+
p256dh: arrayBufferToBase64Url(sub.getKey("p256dh")),
|
|
80
|
+
auth: arrayBufferToBase64Url(sub.getKey("auth"))
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
async function registerPush(opts = {}) {
|
|
84
|
+
assertPushSupported();
|
|
85
|
+
const basePath = opts.basePath ?? DEFAULT_BASE_PATH;
|
|
86
|
+
const swPath = opts.swPath ?? DEFAULT_SW_PATH;
|
|
87
|
+
const topics = opts.topics ?? [];
|
|
88
|
+
const userId = opts.userId ?? null;
|
|
89
|
+
const visitorId = resolveVisitorId(userId, opts.visitorId);
|
|
90
|
+
const publicKey = await fetchVapidPublicKey(basePath);
|
|
91
|
+
const registration = await navigator.serviceWorker.register(swPath);
|
|
92
|
+
await navigator.serviceWorker.ready;
|
|
93
|
+
const existing = await registration.pushManager.getSubscription();
|
|
94
|
+
const subscription = existing ?? await registration.pushManager.subscribe({
|
|
95
|
+
userVisibleOnly: true,
|
|
96
|
+
applicationServerKey: base64UrlToArrayBuffer(publicKey)
|
|
97
|
+
});
|
|
98
|
+
const { p256dh, auth } = extractKeys(subscription);
|
|
99
|
+
const res = await fetch(`${basePath}/subscribe`, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
credentials: "same-origin",
|
|
102
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
103
|
+
body: JSON.stringify({
|
|
104
|
+
provider: "web-push",
|
|
105
|
+
endpoint: subscription.endpoint,
|
|
106
|
+
p256dh,
|
|
107
|
+
auth,
|
|
108
|
+
userId,
|
|
109
|
+
visitorId,
|
|
110
|
+
topics
|
|
111
|
+
})
|
|
112
|
+
});
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
throw new Error(`Subscribe failed: ${res.status} ${res.statusText}`);
|
|
115
|
+
}
|
|
116
|
+
const saved = await res.json();
|
|
117
|
+
if (!saved || typeof saved.id !== "string" || saved.id.length === 0) {
|
|
118
|
+
throw new Error("Subscribe response is missing `id`");
|
|
119
|
+
}
|
|
120
|
+
return { subscription, subscriptionId: saved.id };
|
|
121
|
+
}
|
|
122
|
+
async function unregisterPush(subscriptionId, opts = {}) {
|
|
123
|
+
if (!subscriptionId) {
|
|
124
|
+
throw new Error("subscriptionId is required");
|
|
125
|
+
}
|
|
126
|
+
const basePath = opts.basePath ?? DEFAULT_BASE_PATH;
|
|
127
|
+
const res = await fetch(`${basePath}/unsubscribe`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
credentials: "same-origin",
|
|
130
|
+
headers: { "Content-Type": "application/json" },
|
|
131
|
+
body: JSON.stringify({ subscriptionId })
|
|
132
|
+
});
|
|
133
|
+
if (!res.ok && res.status !== 404) {
|
|
134
|
+
throw new Error(`Unsubscribe failed: ${res.status} ${res.statusText}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export {
|
|
138
|
+
registerPush,
|
|
139
|
+
unregisterPush
|
|
140
|
+
};
|
|
141
|
+
//# sourceMappingURL=push.js.map
|
package/dist/push.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/push.ts"],"sourcesContent":["/**\n * @fileoverview Web Push client SDK for Maravilla tenants.\n *\n * Talks to the tenant-origin `/_platform/push` endpoints served by delivery:\n * - GET /vapid-public-key — fetch the VAPID public key for subscribe\n * - POST /subscribe — create a subscription\n * - POST /unsubscribe — delete a subscription by id (or endpoint)\n *\n * The matching service worker is served from `/_platform/push/sw.js` on the\n * same tenant origin (browsers require same-origin SW registration).\n */\n\nconst DEFAULT_BASE_PATH = '/_platform/push';\nconst DEFAULT_SW_PATH = '/_platform/push/sw.js';\nconst VISITOR_STORAGE_KEY = 'maravilla.push.visitorId';\n\nexport interface RegisterPushOptions {\n /** Free-form topic strings to tag this subscription with. */\n topics?: string[];\n /** Authenticated user id, if any. Takes precedence over visitorId. */\n userId?: string | null;\n /** Anonymous visitor id. If omitted and userId is also omitted, one\n * is generated and persisted to localStorage under `maravilla.push.visitorId`. */\n visitorId?: string | null;\n /** Override the sw.js path (defaults to `/_platform/push/sw.js`). */\n swPath?: string;\n /** Override the API base path (defaults to `/_platform/push`). */\n basePath?: string;\n}\n\nexport interface RegisterPushResult {\n /** The browser PushSubscription (see Web Push spec). */\n subscription: PushSubscription;\n /** Server-issued subscription id — pass this back to unregisterPush. */\n subscriptionId: string;\n}\n\ninterface VapidPublicKeyResponse {\n publicKey: string;\n contactEmail?: string;\n}\n\ninterface ServerSubscription {\n id: string;\n [key: string]: unknown;\n}\n\nfunction assertPushSupported(): void {\n if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {\n throw new Error('Web Push is not supported: serviceWorker is unavailable');\n }\n if (typeof window === 'undefined' || !('PushManager' in window)) {\n throw new Error('Web Push is not supported: PushManager is unavailable');\n }\n}\n\nfunction base64UrlToArrayBuffer(input: string): ArrayBuffer {\n const padding = '='.repeat((4 - (input.length % 4)) % 4);\n const base64 = (input + padding).replace(/-/g, '+').replace(/_/g, '/');\n const raw = atob(base64);\n const buffer = new ArrayBuffer(raw.length);\n const view = new Uint8Array(buffer);\n for (let i = 0; i < raw.length; i++) {\n view[i] = raw.charCodeAt(i);\n }\n return buffer;\n}\n\nfunction arrayBufferToBase64Url(buffer: ArrayBuffer | null): string | undefined {\n if (!buffer) return undefined;\n const bytes = new Uint8Array(buffer);\n let binary = '';\n for (let i = 0; i < bytes.length; i++) {\n binary += String.fromCharCode(bytes[i]);\n }\n return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n}\n\nfunction randomUuid(): string {\n const c = typeof crypto !== 'undefined' ? crypto : undefined;\n if (c && typeof c.randomUUID === 'function') {\n return c.randomUUID();\n }\n const bytes = new Uint8Array(16);\n if (c && typeof c.getRandomValues === 'function') {\n c.getRandomValues(bytes);\n } else {\n for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);\n }\n bytes[6] = (bytes[6] & 0x0f) | 0x40;\n bytes[8] = (bytes[8] & 0x3f) | 0x80;\n const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;\n}\n\nfunction resolveVisitorId(\n userId: string | null | undefined,\n visitorId: string | null | undefined,\n): string | null {\n if (visitorId) return visitorId;\n if (userId) return null;\n try {\n const stored = window.localStorage.getItem(VISITOR_STORAGE_KEY);\n if (stored) return stored;\n const fresh = randomUuid();\n window.localStorage.setItem(VISITOR_STORAGE_KEY, fresh);\n return fresh;\n } catch {\n return randomUuid();\n }\n}\n\nasync function fetchVapidPublicKey(basePath: string): Promise<string> {\n const res = await fetch(`${basePath}/vapid-public-key`, {\n method: 'GET',\n credentials: 'same-origin',\n headers: { Accept: 'application/json' },\n });\n if (!res.ok) {\n throw new Error(`Failed to fetch VAPID public key: ${res.status} ${res.statusText}`);\n }\n const body = (await res.json()) as VapidPublicKeyResponse;\n if (!body || typeof body.publicKey !== 'string' || body.publicKey.length === 0) {\n throw new Error('VAPID public key response is missing `publicKey`');\n }\n return body.publicKey;\n}\n\nfunction extractKeys(sub: PushSubscription): { p256dh?: string; auth?: string } {\n return {\n p256dh: arrayBufferToBase64Url(sub.getKey('p256dh')),\n auth: arrayBufferToBase64Url(sub.getKey('auth')),\n };\n}\n\nexport async function registerPush(\n opts: RegisterPushOptions = {},\n): Promise<RegisterPushResult> {\n assertPushSupported();\n\n const basePath = opts.basePath ?? DEFAULT_BASE_PATH;\n const swPath = opts.swPath ?? DEFAULT_SW_PATH;\n const topics = opts.topics ?? [];\n const userId = opts.userId ?? null;\n const visitorId = resolveVisitorId(userId, opts.visitorId);\n\n const publicKey = await fetchVapidPublicKey(basePath);\n const registration = await navigator.serviceWorker.register(swPath);\n await navigator.serviceWorker.ready;\n\n const existing = await registration.pushManager.getSubscription();\n const subscription =\n existing ??\n (await registration.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: base64UrlToArrayBuffer(publicKey),\n }));\n\n const { p256dh, auth } = extractKeys(subscription);\n\n const res = await fetch(`${basePath}/subscribe`, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json', Accept: 'application/json' },\n body: JSON.stringify({\n provider: 'web-push',\n endpoint: subscription.endpoint,\n p256dh,\n auth,\n userId,\n visitorId,\n topics,\n }),\n });\n\n if (!res.ok) {\n throw new Error(`Subscribe failed: ${res.status} ${res.statusText}`);\n }\n\n const saved = (await res.json()) as ServerSubscription;\n if (!saved || typeof saved.id !== 'string' || saved.id.length === 0) {\n throw new Error('Subscribe response is missing `id`');\n }\n\n return { subscription, subscriptionId: saved.id };\n}\n\nexport async function unregisterPush(\n subscriptionId: string,\n opts: { basePath?: string } = {},\n): Promise<void> {\n if (!subscriptionId) {\n throw new Error('subscriptionId is required');\n }\n const basePath = opts.basePath ?? DEFAULT_BASE_PATH;\n\n const res = await fetch(`${basePath}/unsubscribe`, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ subscriptionId }),\n });\n\n if (!res.ok && res.status !== 404) {\n throw new Error(`Unsubscribe failed: ${res.status} ${res.statusText}`);\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maravilla-labs/platform",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.39",
|
|
4
4
|
"description": "Universal platform client for Maravilla runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
".": {
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
12
12
|
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./config": {
|
|
15
|
+
"types": "./dist/config.d.ts",
|
|
16
|
+
"import": "./dist/config.js"
|
|
17
|
+
},
|
|
18
|
+
"./push": {
|
|
19
|
+
"types": "./dist/push.d.ts",
|
|
20
|
+
"import": "./dist/push.js"
|
|
13
21
|
}
|
|
14
22
|
},
|
|
15
23
|
"scripts": {
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Typed schema for `maravilla.config.{ts,yaml,json}` files.
|
|
3
|
+
*
|
|
4
|
+
* Declares your project's auth settings (resources, groups, relations,
|
|
5
|
+
* registration fields, OAuth providers, security policy, branding) alongside
|
|
6
|
+
* your code. The Maravilla adapter reads this at build time and reconciles
|
|
7
|
+
* the settings into delivery on deploy.
|
|
8
|
+
*
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { defineConfig } from '@maravilla-labs/platform/config';
|
|
11
|
+
*
|
|
12
|
+
* export default defineConfig({
|
|
13
|
+
* auth: {
|
|
14
|
+
* resources: [
|
|
15
|
+
* { name: 'todos', title: 'Todos', actions: ['read', 'write'],
|
|
16
|
+
* policy: 'auth.user_id == node.owner' },
|
|
17
|
+
* ],
|
|
18
|
+
* },
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Omitted sections leave the DB alone — partial adoption is explicitly
|
|
23
|
+
* supported. List-based sections (`resources`, `groups`, `relations`,
|
|
24
|
+
* `oauth`) are upserted and never auto-delete DB-only entries. Singleton
|
|
25
|
+
* sections (`registration`, `security`, `branding`) are replaced wholesale
|
|
26
|
+
* when declared.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* String value that may either be a literal secret or a reference to an
|
|
31
|
+
* environment variable on the **tenant** (resolved server-side at
|
|
32
|
+
* reconcile time, never shipped plaintext in the manifest).
|
|
33
|
+
*
|
|
34
|
+
* Accepted forms:
|
|
35
|
+
* - `"literal-value"` — inline (not recommended for real secrets)
|
|
36
|
+
* - `"${env.VAR_NAME}"` — string-template form
|
|
37
|
+
* - `{ env: "VAR_NAME" }` — object form
|
|
38
|
+
*/
|
|
39
|
+
export type SecretRef = string | { env: string };
|
|
40
|
+
|
|
41
|
+
// ── Resources + policies ──
|
|
42
|
+
|
|
43
|
+
export interface ResourceDefinition {
|
|
44
|
+
/** URL-safe slug. Used as the resource key in code (e.g. the KV namespace). */
|
|
45
|
+
name: string;
|
|
46
|
+
/** Human-readable title for the admin UI. */
|
|
47
|
+
title: string;
|
|
48
|
+
/** Optional longer description. */
|
|
49
|
+
description?: string;
|
|
50
|
+
/** Actions this resource supports, e.g. `['read', 'write', 'delete']`. */
|
|
51
|
+
actions: string[];
|
|
52
|
+
/**
|
|
53
|
+
* Optional raisin-rel policy expression. Evaluated on every KV/DB/
|
|
54
|
+
* realtime/media op that targets this resource. Leave empty to skip
|
|
55
|
+
* Layer 2 for this resource — tenant + owner isolation still applies.
|
|
56
|
+
*/
|
|
57
|
+
policy?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Groups ──
|
|
61
|
+
|
|
62
|
+
export interface GroupPermissionDefinition {
|
|
63
|
+
/** Must match a `ResourceDefinition.name`. */
|
|
64
|
+
resource_name: string;
|
|
65
|
+
/** Actions this group is granted on the resource. */
|
|
66
|
+
actions: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface GroupDefinition {
|
|
70
|
+
/** Unique group name per tenant. */
|
|
71
|
+
name: string;
|
|
72
|
+
/** Optional description for the admin UI. */
|
|
73
|
+
description?: string;
|
|
74
|
+
/** Resource permissions granted to the group. Replaces the group's current permissions when declared. */
|
|
75
|
+
permissions?: GroupPermissionDefinition[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Relations ──
|
|
79
|
+
|
|
80
|
+
export interface RelationTypeDefinition {
|
|
81
|
+
/** Uppercase identifier used in policies (`... VIA 'STEWARDS'`). */
|
|
82
|
+
relation_name: string;
|
|
83
|
+
/** Human-readable title. */
|
|
84
|
+
title: string;
|
|
85
|
+
description?: string;
|
|
86
|
+
/** Grouping for the admin UI (e.g. `"family"`, `"work"`). */
|
|
87
|
+
category?: string;
|
|
88
|
+
icon?: string;
|
|
89
|
+
color?: string;
|
|
90
|
+
/** Name of the inverse relation type, if one exists. */
|
|
91
|
+
inverse_relation_name?: string;
|
|
92
|
+
/** When true, membership in this relation implies stewardship rights. */
|
|
93
|
+
implies_stewardship?: boolean;
|
|
94
|
+
/** When true, the relation can only target users flagged as minors. */
|
|
95
|
+
requires_minor?: boolean;
|
|
96
|
+
/** When true, the relation is symmetric (A→B implies B→A). */
|
|
97
|
+
bidirectional?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Registration fields ──
|
|
101
|
+
|
|
102
|
+
export interface RegistrationFieldDefinition {
|
|
103
|
+
/** Field key used as the form field name + in profile data. */
|
|
104
|
+
key: string;
|
|
105
|
+
/** Display label. */
|
|
106
|
+
label: string;
|
|
107
|
+
/** One of: text, email, phone, date, number, select, boolean, url, textarea. */
|
|
108
|
+
field_type: string;
|
|
109
|
+
required: boolean;
|
|
110
|
+
show_on_register: boolean;
|
|
111
|
+
/** Optional validation metadata — passed through to the UI. */
|
|
112
|
+
validation?: Record<string, unknown>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface RegistrationConfig {
|
|
116
|
+
/** Ordered list of custom registration fields. Declaring this replaces the full list. */
|
|
117
|
+
fields: RegistrationFieldDefinition[];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── OAuth providers ──
|
|
121
|
+
|
|
122
|
+
export interface OAuthProviderDefinition {
|
|
123
|
+
enabled: boolean;
|
|
124
|
+
client_id: string;
|
|
125
|
+
/** Prefer `{ env: "VAR_NAME" }` or `"${env.VAR_NAME}"`. */
|
|
126
|
+
client_secret: SecretRef;
|
|
127
|
+
scopes: string[];
|
|
128
|
+
/** Only for `custom_oidc`. */
|
|
129
|
+
discovery_url?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface OAuthProvidersConfig {
|
|
133
|
+
google?: OAuthProviderDefinition;
|
|
134
|
+
github?: OAuthProviderDefinition;
|
|
135
|
+
okta?: OAuthProviderDefinition;
|
|
136
|
+
custom_oidc?: OAuthProviderDefinition;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Security ──
|
|
140
|
+
|
|
141
|
+
export interface PasswordPolicyDefinition {
|
|
142
|
+
min_length: number;
|
|
143
|
+
require_uppercase: boolean;
|
|
144
|
+
require_number: boolean;
|
|
145
|
+
require_special: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface SessionConfigDefinition {
|
|
149
|
+
access_token_ttl_secs: number;
|
|
150
|
+
refresh_token_ttl_secs: number;
|
|
151
|
+
max_sessions_per_user: number;
|
|
152
|
+
require_email_verification: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface SecurityConfig {
|
|
156
|
+
password_policy?: PasswordPolicyDefinition;
|
|
157
|
+
session?: SessionConfigDefinition;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Branding ──
|
|
161
|
+
|
|
162
|
+
export interface BrandingConfig {
|
|
163
|
+
app_name?: string;
|
|
164
|
+
logo_url?: string;
|
|
165
|
+
primary_color?: string;
|
|
166
|
+
secondary_color?: string;
|
|
167
|
+
welcome_message?: string;
|
|
168
|
+
welcome_subtitle?: string;
|
|
169
|
+
/** `"centered"`, `"split-left"`, `"split-right"`, or `"fullscreen"`. */
|
|
170
|
+
layout?: string;
|
|
171
|
+
background_image_url?: string;
|
|
172
|
+
/** 0–100 percentage. */
|
|
173
|
+
background_focal_point?: { x: number; y: number };
|
|
174
|
+
background_gradient?: string;
|
|
175
|
+
/** `"light"`, `"dark"`, or `"auto"`. */
|
|
176
|
+
color_mode?: string;
|
|
177
|
+
font_family?: string;
|
|
178
|
+
terms_url?: string;
|
|
179
|
+
privacy_url?: string;
|
|
180
|
+
/** Raw CSS merged into the hosted auth pages. */
|
|
181
|
+
custom_css?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Top-level shape ──
|
|
185
|
+
|
|
186
|
+
export interface AuthConfigBlock {
|
|
187
|
+
resources?: ResourceDefinition[];
|
|
188
|
+
groups?: GroupDefinition[];
|
|
189
|
+
relations?: RelationTypeDefinition[];
|
|
190
|
+
registration?: RegistrationConfig;
|
|
191
|
+
oauth?: OAuthProvidersConfig;
|
|
192
|
+
security?: SecurityConfig;
|
|
193
|
+
branding?: BrandingConfig;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface MaravillaConfig {
|
|
197
|
+
/** All project-level auth settings. Every field is optional — partial adoption is supported. */
|
|
198
|
+
auth?: AuthConfigBlock;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Identity function that returns the config unchanged — exists purely so the
|
|
203
|
+
* TypeScript compiler can infer `MaravillaConfig` and give you IntelliSense
|
|
204
|
+
* on every field.
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* ```typescript
|
|
208
|
+
* import { defineConfig } from '@maravilla-labs/platform/config';
|
|
209
|
+
*
|
|
210
|
+
* export default defineConfig({
|
|
211
|
+
* auth: {
|
|
212
|
+
* resources: [{ name: 'todos', title: 'Todos', actions: ['read', 'write'] }],
|
|
213
|
+
* },
|
|
214
|
+
* });
|
|
215
|
+
* ```
|
|
216
|
+
*/
|
|
217
|
+
export function defineConfig(config: MaravillaConfig): MaravillaConfig {
|
|
218
|
+
return config;
|
|
219
|
+
}
|
package/src/index.ts
CHANGED
package/src/push.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Web Push client SDK for Maravilla tenants.
|
|
3
|
+
*
|
|
4
|
+
* Talks to the tenant-origin `/_platform/push` endpoints served by delivery:
|
|
5
|
+
* - GET /vapid-public-key — fetch the VAPID public key for subscribe
|
|
6
|
+
* - POST /subscribe — create a subscription
|
|
7
|
+
* - POST /unsubscribe — delete a subscription by id (or endpoint)
|
|
8
|
+
*
|
|
9
|
+
* The matching service worker is served from `/_platform/push/sw.js` on the
|
|
10
|
+
* same tenant origin (browsers require same-origin SW registration).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const DEFAULT_BASE_PATH = '/_platform/push';
|
|
14
|
+
const DEFAULT_SW_PATH = '/_platform/push/sw.js';
|
|
15
|
+
const VISITOR_STORAGE_KEY = 'maravilla.push.visitorId';
|
|
16
|
+
|
|
17
|
+
export interface RegisterPushOptions {
|
|
18
|
+
/** Free-form topic strings to tag this subscription with. */
|
|
19
|
+
topics?: string[];
|
|
20
|
+
/** Authenticated user id, if any. Takes precedence over visitorId. */
|
|
21
|
+
userId?: string | null;
|
|
22
|
+
/** Anonymous visitor id. If omitted and userId is also omitted, one
|
|
23
|
+
* is generated and persisted to localStorage under `maravilla.push.visitorId`. */
|
|
24
|
+
visitorId?: string | null;
|
|
25
|
+
/** Override the sw.js path (defaults to `/_platform/push/sw.js`). */
|
|
26
|
+
swPath?: string;
|
|
27
|
+
/** Override the API base path (defaults to `/_platform/push`). */
|
|
28
|
+
basePath?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RegisterPushResult {
|
|
32
|
+
/** The browser PushSubscription (see Web Push spec). */
|
|
33
|
+
subscription: PushSubscription;
|
|
34
|
+
/** Server-issued subscription id — pass this back to unregisterPush. */
|
|
35
|
+
subscriptionId: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface VapidPublicKeyResponse {
|
|
39
|
+
publicKey: string;
|
|
40
|
+
contactEmail?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface ServerSubscription {
|
|
44
|
+
id: string;
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function assertPushSupported(): void {
|
|
49
|
+
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
|
50
|
+
throw new Error('Web Push is not supported: serviceWorker is unavailable');
|
|
51
|
+
}
|
|
52
|
+
if (typeof window === 'undefined' || !('PushManager' in window)) {
|
|
53
|
+
throw new Error('Web Push is not supported: PushManager is unavailable');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function base64UrlToArrayBuffer(input: string): ArrayBuffer {
|
|
58
|
+
const padding = '='.repeat((4 - (input.length % 4)) % 4);
|
|
59
|
+
const base64 = (input + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
60
|
+
const raw = atob(base64);
|
|
61
|
+
const buffer = new ArrayBuffer(raw.length);
|
|
62
|
+
const view = new Uint8Array(buffer);
|
|
63
|
+
for (let i = 0; i < raw.length; i++) {
|
|
64
|
+
view[i] = raw.charCodeAt(i);
|
|
65
|
+
}
|
|
66
|
+
return buffer;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function arrayBufferToBase64Url(buffer: ArrayBuffer | null): string | undefined {
|
|
70
|
+
if (!buffer) return undefined;
|
|
71
|
+
const bytes = new Uint8Array(buffer);
|
|
72
|
+
let binary = '';
|
|
73
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
74
|
+
binary += String.fromCharCode(bytes[i]);
|
|
75
|
+
}
|
|
76
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function randomUuid(): string {
|
|
80
|
+
const c = typeof crypto !== 'undefined' ? crypto : undefined;
|
|
81
|
+
if (c && typeof c.randomUUID === 'function') {
|
|
82
|
+
return c.randomUUID();
|
|
83
|
+
}
|
|
84
|
+
const bytes = new Uint8Array(16);
|
|
85
|
+
if (c && typeof c.getRandomValues === 'function') {
|
|
86
|
+
c.getRandomValues(bytes);
|
|
87
|
+
} else {
|
|
88
|
+
for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
89
|
+
}
|
|
90
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
91
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
92
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
93
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveVisitorId(
|
|
97
|
+
userId: string | null | undefined,
|
|
98
|
+
visitorId: string | null | undefined,
|
|
99
|
+
): string | null {
|
|
100
|
+
if (visitorId) return visitorId;
|
|
101
|
+
if (userId) return null;
|
|
102
|
+
try {
|
|
103
|
+
const stored = window.localStorage.getItem(VISITOR_STORAGE_KEY);
|
|
104
|
+
if (stored) return stored;
|
|
105
|
+
const fresh = randomUuid();
|
|
106
|
+
window.localStorage.setItem(VISITOR_STORAGE_KEY, fresh);
|
|
107
|
+
return fresh;
|
|
108
|
+
} catch {
|
|
109
|
+
return randomUuid();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function fetchVapidPublicKey(basePath: string): Promise<string> {
|
|
114
|
+
const res = await fetch(`${basePath}/vapid-public-key`, {
|
|
115
|
+
method: 'GET',
|
|
116
|
+
credentials: 'same-origin',
|
|
117
|
+
headers: { Accept: 'application/json' },
|
|
118
|
+
});
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
throw new Error(`Failed to fetch VAPID public key: ${res.status} ${res.statusText}`);
|
|
121
|
+
}
|
|
122
|
+
const body = (await res.json()) as VapidPublicKeyResponse;
|
|
123
|
+
if (!body || typeof body.publicKey !== 'string' || body.publicKey.length === 0) {
|
|
124
|
+
throw new Error('VAPID public key response is missing `publicKey`');
|
|
125
|
+
}
|
|
126
|
+
return body.publicKey;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function extractKeys(sub: PushSubscription): { p256dh?: string; auth?: string } {
|
|
130
|
+
return {
|
|
131
|
+
p256dh: arrayBufferToBase64Url(sub.getKey('p256dh')),
|
|
132
|
+
auth: arrayBufferToBase64Url(sub.getKey('auth')),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function registerPush(
|
|
137
|
+
opts: RegisterPushOptions = {},
|
|
138
|
+
): Promise<RegisterPushResult> {
|
|
139
|
+
assertPushSupported();
|
|
140
|
+
|
|
141
|
+
const basePath = opts.basePath ?? DEFAULT_BASE_PATH;
|
|
142
|
+
const swPath = opts.swPath ?? DEFAULT_SW_PATH;
|
|
143
|
+
const topics = opts.topics ?? [];
|
|
144
|
+
const userId = opts.userId ?? null;
|
|
145
|
+
const visitorId = resolveVisitorId(userId, opts.visitorId);
|
|
146
|
+
|
|
147
|
+
const publicKey = await fetchVapidPublicKey(basePath);
|
|
148
|
+
const registration = await navigator.serviceWorker.register(swPath);
|
|
149
|
+
await navigator.serviceWorker.ready;
|
|
150
|
+
|
|
151
|
+
const existing = await registration.pushManager.getSubscription();
|
|
152
|
+
const subscription =
|
|
153
|
+
existing ??
|
|
154
|
+
(await registration.pushManager.subscribe({
|
|
155
|
+
userVisibleOnly: true,
|
|
156
|
+
applicationServerKey: base64UrlToArrayBuffer(publicKey),
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
const { p256dh, auth } = extractKeys(subscription);
|
|
160
|
+
|
|
161
|
+
const res = await fetch(`${basePath}/subscribe`, {
|
|
162
|
+
method: 'POST',
|
|
163
|
+
credentials: 'same-origin',
|
|
164
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
165
|
+
body: JSON.stringify({
|
|
166
|
+
provider: 'web-push',
|
|
167
|
+
endpoint: subscription.endpoint,
|
|
168
|
+
p256dh,
|
|
169
|
+
auth,
|
|
170
|
+
userId,
|
|
171
|
+
visitorId,
|
|
172
|
+
topics,
|
|
173
|
+
}),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
throw new Error(`Subscribe failed: ${res.status} ${res.statusText}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const saved = (await res.json()) as ServerSubscription;
|
|
181
|
+
if (!saved || typeof saved.id !== 'string' || saved.id.length === 0) {
|
|
182
|
+
throw new Error('Subscribe response is missing `id`');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { subscription, subscriptionId: saved.id };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function unregisterPush(
|
|
189
|
+
subscriptionId: string,
|
|
190
|
+
opts: { basePath?: string } = {},
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
if (!subscriptionId) {
|
|
193
|
+
throw new Error('subscriptionId is required');
|
|
194
|
+
}
|
|
195
|
+
const basePath = opts.basePath ?? DEFAULT_BASE_PATH;
|
|
196
|
+
|
|
197
|
+
const res = await fetch(`${basePath}/unsubscribe`, {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
credentials: 'same-origin',
|
|
200
|
+
headers: { 'Content-Type': 'application/json' },
|
|
201
|
+
body: JSON.stringify({ subscriptionId }),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (!res.ok && res.status !== 404) {
|
|
205
|
+
throw new Error(`Unsubscribe failed: ${res.status} ${res.statusText}`);
|
|
206
|
+
}
|
|
207
|
+
}
|