@ambasada/miniapp 0.0.0-stage → 0.6.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 CENTYSO (https://centyso.com)
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,98 @@
1
- # Temporary Holding Version
1
+ # @ambasada/miniapp
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
+ The page side of the Ambasada app's mini-app shell. Call `ready()` when your first screen is drawn, read the
4
+ launch context, respect the safe areas and the host bar, open links and other mini apps, ask for haptics,
5
+ silent sign-in and notifications. Outside the Ambasada app every call is a safe no-op, so the same page is
6
+ also a plain website. Zero dependencies, ~3 KB.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ npm install @ambasada/miniapp
12
+ ```
13
+
14
+ ```ts
15
+ import { ready, context, on } from "@ambasada/miniapp";
16
+
17
+ ready(); // after the first render: the shell drops the splash
18
+ ```
19
+
20
+ Or, with no build step, the `<script>` build the hub serves (defines `window.ambasada`):
21
+
22
+ ```html
23
+ <script src="https://hub.ambasada.id/miniapp.js" defer></script>
24
+ <script>addEventListener("DOMContentLoaded", () => window.ambasada?.ready?.())</script>
25
+ ```
26
+
27
+ The `ambasada init` templates ship both forms, and `ambasada check` warns when a page uses neither.
28
+
29
+ ## API
30
+
31
+ | Call | Inside the app | Outside |
32
+ |---|---|---|
33
+ | `ready()` | tells the shell the page is drawn (once; idempotent); sets the CSS variables | sets the CSS variables to 0 |
34
+ | `shell()` | the injected `window.ambasadaShell` (version ≥ 2) | `null` |
35
+ | `isInAmbasada()` | `true` (also for older builds that only set the `AmbasadaChat/` user agent) | `false` |
36
+ | `context()` | `{ app, platform, safeArea, hostBar, theme, locale, user, location }`, with defaults for what an older shell omits | the defaults |
37
+ | `capabilities()` | what this shell build supports (version 2: `openUrl`, `close`) | `[]` |
38
+ | `applyCssVars()` | writes `--ambasada-safe-area-{top,bottom,left,right}` and `--ambasada-host-bar` on `<html>` and toggles the `in-ambasada` class | same, with zeros |
39
+ | `openUrl(url)` | the system browser (verified apps) or a confirm sheet | a new tab |
40
+ | `openApp(id, path?)` | another mini app on top of this one | `https://ambasada.id/a/<id><path>` in a new tab |
41
+ | `close()` | closes this mini app | `history.back()` |
42
+ | `share({ url?, text? })` | the system share sheet | the Web Share API, or nothing |
43
+ | `haptics.impact(style?)`, `.notification(style?)`, `.selection()` | haptics | nothing |
44
+ | `setTitle(title)` | the host bar's title and `document.title` | `document.title` |
45
+ | `auth.getToken()` | a 60 s JWT (`aud` = your origin, `sub` = the Ambasada user id), verified apps with the `identity` scope; verify it against `https://hub.ambasada.id/.well-known/jwks.json` | rejects: fall back to Sign in with Ambasada |
46
+ | `notifications.request()` | the shell's permission sheet → `{ enabled }`; your webhook gets `notifications_enabled` | `{ enabled: false }` |
47
+ | `on("context", fn)` / `on("notification", fn)` | a new context (safe areas, theme) / a notification tap `{ id, url }`; returns the unsubscribe | never fires |
48
+ | `mockEnv(overrides?)` | local development: installs a version 3 shell, records `messages`, answers requests, returns `{ messages, emit, uninstall }` | same (`ambasada dev` uses it) |
49
+
50
+ Layout: pad your first screen by `var(--ambasada-safe-area-top, 0px) + var(--ambasada-host-bar, 0px)` at the top
51
+ and `var(--ambasada-safe-area-bottom, 0px)` at the bottom. The `0px` fallbacks keep the page right on the web.
52
+
53
+ ## The bridge protocol
54
+
55
+ The native app injects `window.ambasadaShell` before the page loads:
56
+
57
+ ```ts
58
+ { version: 3, platform: "ios" | "android" | "web", capabilities?: Capability[],
59
+ app: { id, tier } | null, safeArea: { top, bottom, left, right }, hostBar: number,
60
+ theme: { accent, scheme: "light" }, locale, user: { id, name, avatar } | null, location: LaunchLocation }
61
+ ```
62
+
63
+ Version 2 shells (the current app) carry only `version`, `platform` and `safeArea`; the SDK falls back to the
64
+ v2 messages (`chrome`, `open`) for them.
65
+
66
+ **Page → app**: one JSON message per call, through `webkit.messageHandlers.ambasada.postMessage(obj)` on iOS or
67
+ `window.ambasada.postMessage(json)` on Android:
68
+
69
+ | Message | Fields |
70
+ |---|---|
71
+ | `ready` | |
72
+ | `openUrl` | `url` |
73
+ | `openApp` | `app`, `path?` |
74
+ | `close` | |
75
+ | `share` | `url?`, `text?` |
76
+ | `haptic` | `kind: impact \| notification \| selection`, `style?` |
77
+ | `title` | `title` |
78
+ | `request` | `id`, `method: auth.getToken \| notifications.request` |
79
+ | `chrome` (v2) | `header?`, `tabs?`, `title?`, `back?`, `statusBar?` |
80
+ | `open` (v2) | `url`, `mode: in-app \| external \| sheet` |
81
+
82
+ **App → page**: `CustomEvent`s dispatched on `window`:
83
+
84
+ | Event | `detail` |
85
+ |---|---|
86
+ | `ambasada:reply` | `{ id, result }` or `{ id, error }`, answering a `request` (the SDK gives up after 15 s) |
87
+ | `ambasada:context` | a (partial) new `ShellContext`; the SDK merges it into `window.ambasadaShell` and re-applies the CSS variables |
88
+ | `ambasada:notification` | `{ id, url }`, a notification tapped while this app is open |
89
+
90
+ The `<script>` build merges into an existing `window.ambasada` (Android's bridge object) instead of replacing
91
+ it, so `window.ambasada.postMessage` keeps working next to `window.ambasada.ready()`.
92
+
93
+ ## Build
94
+
95
+ `node scripts/build.mjs` writes `dist/index.js` (ESM), `dist/miniapp.js` (classic script, global `ambasada`) and
96
+ `dist/index.d.ts`. The hub serves `dist/miniapp.js` at `https://hub.ambasada.id/miniapp.js`.
97
+
98
+ MIT, CENTYSO.
@@ -0,0 +1,239 @@
1
+ /**
2
+ * @ambasada/miniapp: the page side of the Ambasada app's mini-app shell (docs/MINI-APPS.md §6).
3
+ *
4
+ * The native app injects `window.ambasadaShell` before the page loads (version 3; version 2 shells
5
+ * carry only `version`, `platform` and `safeArea`). Pages talk back over `webkit.messageHandlers.ambasada`
6
+ * (iOS) or `window.ambasada.postMessage` (Android). The shell answers requests and pushes events by
7
+ * dispatching `CustomEvent`s on `window`: `ambasada:reply` ({ id, result | error }), `ambasada:context`
8
+ * (a new context), `ambasada:notification` ({ id, url }).
9
+ *
10
+ * Outside the Ambasada app every call is a safe no-op (promises resolve to a "not in Ambasada" answer),
11
+ * so the same page is also a plain website. No dependencies; built as ESM and as a classic <script>.
12
+ */
13
+ export interface SafeArea {
14
+ top: number;
15
+ bottom: number;
16
+ left: number;
17
+ right: number;
18
+ }
19
+ export type LaunchLocation = {
20
+ type: "launcher";
21
+ } | {
22
+ type: "notification";
23
+ id: string;
24
+ url: string;
25
+ } | {
26
+ type: "link";
27
+ url: string;
28
+ referrer?: string;
29
+ } | {
30
+ type: "app";
31
+ referrer: string;
32
+ } | {
33
+ type: "share";
34
+ };
35
+ /** What the shell knows about this launch. `user` is display data only: trust comes from `auth.getToken()`. */
36
+ export interface ShellContext {
37
+ app: {
38
+ id: string;
39
+ tier: "unlisted" | "listed" | "verified" | "first_party";
40
+ } | null;
41
+ platform: "ios" | "android" | "web";
42
+ safeArea: SafeArea;
43
+ /** The host bar's height in CSS px (0 on version 2 shells, which draw no bar). */
44
+ hostBar: number;
45
+ theme: {
46
+ accent: string;
47
+ scheme: "light";
48
+ };
49
+ locale: string;
50
+ user: {
51
+ id: string;
52
+ name: string | null;
53
+ avatar: string | null;
54
+ } | null;
55
+ location: LaunchLocation;
56
+ }
57
+ export type Capability = "ready" | "openUrl" | "openApp" | "close" | "share" | "haptics" | "title" | "auth.getToken" | "notifications";
58
+ /** What the native app injects. Version 3 fills everything; version 2 only `version`, `platform`, `safeArea`. */
59
+ export interface InjectedShell extends Partial<ShellContext> {
60
+ version: number;
61
+ platform: "ios" | "android" | "web";
62
+ capabilities?: Capability[];
63
+ }
64
+ type Message = {
65
+ type: "ready";
66
+ } | {
67
+ type: "openUrl";
68
+ url: string;
69
+ } | {
70
+ type: "openApp";
71
+ app: string;
72
+ path?: string;
73
+ } | {
74
+ type: "close";
75
+ } | {
76
+ type: "share";
77
+ url?: string;
78
+ text?: string;
79
+ } | {
80
+ type: "haptic";
81
+ kind: "impact" | "notification" | "selection";
82
+ style?: string;
83
+ } | {
84
+ type: "title";
85
+ title: string;
86
+ } | {
87
+ type: "request";
88
+ id: string;
89
+ method: "auth.getToken" | "notifications.request";
90
+ } | {
91
+ type: "chrome";
92
+ header?: "shown" | "hidden";
93
+ tabs?: "shown" | "hidden";
94
+ title?: string;
95
+ back?: "history" | "close" | "none";
96
+ statusBar?: "light" | "dark";
97
+ } | {
98
+ type: "open";
99
+ url: string;
100
+ mode: "in-app" | "external" | "sheet";
101
+ };
102
+ /** The injected shell, if this page runs inside the Ambasada app (version 2 or newer). */
103
+ export declare function shell(): InjectedShell | null;
104
+ /** Inside the Ambasada app? Also true for older app builds that only set the user agent. */
105
+ export declare function isInAmbasada(): boolean;
106
+ /** The launch context, with defaults for whatever an older shell did not provide. */
107
+ export declare function context(): ShellContext;
108
+ /** What this shell build supports. Version 2 shells: open links and close only. */
109
+ export declare function capabilities(): Capability[];
110
+ /** Writes the shell's safe areas and host bar as CSS variables on <html>, and marks it `in-ambasada`. */
111
+ export declare function applyCssVars(): void;
112
+ /**
113
+ * Tell the shell the page is drawn: it drops the splash. Call it once, as soon as your first screen is
114
+ * visible (after the first render, not after data loads). Idempotent; a no-op outside the app.
115
+ */
116
+ export declare function ready(): void;
117
+ /** Open another origin: the system browser (verified apps) or a confirm sheet first (others). Outside the app: a new tab. */
118
+ export declare function openUrl(url: string): void;
119
+ /** Open another mini app on top of this one; closing it comes back here. Outside the app: the app's web link. */
120
+ export declare function openApp(appId: string, path?: string): void;
121
+ /** Close this mini app. Outside the app: history.back(). */
122
+ export declare function close(): void;
123
+ /** The system share sheet with the app's link. Falls back to the Web Share API, then to nothing. */
124
+ export declare function share(data?: {
125
+ url?: string;
126
+ text?: string;
127
+ }): Promise<void>;
128
+ export declare const haptics: {
129
+ impact(style?: "light" | "medium" | "heavy"): void;
130
+ notification(style?: "success" | "warning" | "error"): void;
131
+ selection(): void;
132
+ };
133
+ /** The host bar's title (and document.title). Keep it short; the bar shows about 24 characters. */
134
+ export declare function setTitle(title: string): void;
135
+ export declare const auth: {
136
+ /**
137
+ * Silent sign-in (verified apps with the `identity` scope): a short-lived JWT (60 s, `aud` = this origin,
138
+ * `sub` = the Ambasada user id) to verify on your server against https://hub.ambasada.id/.well-known/jwks.json.
139
+ * Rejects outside the app or when the app is not verified: fall back to Sign in with Ambasada.
140
+ */
141
+ getToken(): Promise<{
142
+ token: string;
143
+ expires_at: string;
144
+ }>;
145
+ /**
146
+ * Silent sign-in in one call: gets a shell token and POSTs it to your server (`/api/auth/shell` in the templates,
147
+ * `village.auth.shell(req)` in @ambasada/sdk), which sets your session cookie. Resolves the server's `user`, or
148
+ * null when the shell can't do it (outside the app, an older app, an app that is not verified): then show
149
+ * "Sign in with Ambasada". Rejects when your server refuses the token.
150
+ */
151
+ signIn<User = unknown>(options?: {
152
+ endpoint?: string;
153
+ }): Promise<User | null>;
154
+ };
155
+ export declare const notifications: {
156
+ /**
157
+ * Ask to send this person notifications. Call it from a tap. The shell shows its own sheet; on accept
158
+ * your webhook receives `notifications_enabled` with the token (docs/MINI-APPS.md §7).
159
+ * Outside the app, or without the capability: `{ enabled: false }`.
160
+ */
161
+ request(): Promise<{
162
+ enabled: boolean;
163
+ }>;
164
+ };
165
+ export type ShellEvent = "context" | "notification";
166
+ /** Listen for a new context (safe areas, theme) or a notification tap while this app is open ({ id, url }). Returns the unsubscribe. */
167
+ export declare function on(event: "context", handler: (ctx: ShellContext) => void): () => void;
168
+ export declare function on(event: "notification", handler: (n: {
169
+ id: string;
170
+ url: string;
171
+ }) => void): () => void;
172
+ /**
173
+ * Local development: pretend to be inside the Ambasada app. Installs a version 3 shell with the given
174
+ * context, records every message in `messages`, answers requests (`getToken` → a fake token, or with `issuer`
175
+ * (the `ambasada dev` mock URL) a real signed one from `<issuer>/dev/shell-token`; `request()` → enabled)
176
+ * and returns `emit` to fire shell events. The templates call it on `?ambasada_shell=mock` under `ambasada dev`.
177
+ */
178
+ export declare function mockEnv(overrides?: Partial<ShellContext> & {
179
+ version?: number;
180
+ capabilities?: Capability[];
181
+ issuer?: string;
182
+ answers?: Partial<Record<"auth.getToken" | "notifications.request", unknown | Error>>;
183
+ }): {
184
+ messages: Message[];
185
+ emit: (event: ShellEvent, detail: unknown) => boolean;
186
+ uninstall: () => void;
187
+ };
188
+ /** Everything as one object, for the <script> build (`window.ambasada.ready()`). */
189
+ declare const _default: {
190
+ shell: typeof shell;
191
+ isInAmbasada: typeof isInAmbasada;
192
+ context: typeof context;
193
+ capabilities: typeof capabilities;
194
+ applyCssVars: typeof applyCssVars;
195
+ ready: typeof ready;
196
+ openUrl: typeof openUrl;
197
+ openApp: typeof openApp;
198
+ close: typeof close;
199
+ share: typeof share;
200
+ haptics: {
201
+ impact(style?: "light" | "medium" | "heavy"): void;
202
+ notification(style?: "success" | "warning" | "error"): void;
203
+ selection(): void;
204
+ };
205
+ setTitle: typeof setTitle;
206
+ auth: {
207
+ /**
208
+ * Silent sign-in (verified apps with the `identity` scope): a short-lived JWT (60 s, `aud` = this origin,
209
+ * `sub` = the Ambasada user id) to verify on your server against https://hub.ambasada.id/.well-known/jwks.json.
210
+ * Rejects outside the app or when the app is not verified: fall back to Sign in with Ambasada.
211
+ */
212
+ getToken(): Promise<{
213
+ token: string;
214
+ expires_at: string;
215
+ }>;
216
+ /**
217
+ * Silent sign-in in one call: gets a shell token and POSTs it to your server (`/api/auth/shell` in the templates,
218
+ * `village.auth.shell(req)` in @ambasada/sdk), which sets your session cookie. Resolves the server's `user`, or
219
+ * null when the shell can't do it (outside the app, an older app, an app that is not verified): then show
220
+ * "Sign in with Ambasada". Rejects when your server refuses the token.
221
+ */
222
+ signIn<User = unknown>(options?: {
223
+ endpoint?: string;
224
+ }): Promise<User | null>;
225
+ };
226
+ notifications: {
227
+ /**
228
+ * Ask to send this person notifications. Call it from a tap. The shell shows its own sheet; on accept
229
+ * your webhook receives `notifications_enabled` with the token (docs/MINI-APPS.md §7).
230
+ * Outside the app, or without the capability: `{ enabled: false }`.
231
+ */
232
+ request(): Promise<{
233
+ enabled: boolean;
234
+ }>;
235
+ };
236
+ on: typeof on;
237
+ mockEnv: typeof mockEnv;
238
+ };
239
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,241 @@
1
+ // ../miniapp/src/index.ts
2
+ var DEFAULT_SAFE_AREA = { top: 0, bottom: 0, left: 0, right: 0 };
3
+ var NOT_IN_AMBASADA = "not inside the Ambasada app";
4
+ var win = () => typeof window === "undefined" ? null : window;
5
+ function shell() {
6
+ const s = win()?.ambasadaShell;
7
+ return s && typeof s.version === "number" && s.version >= 2 ? s : null;
8
+ }
9
+ function isInAmbasada() {
10
+ const w = win();
11
+ if (!w) return false;
12
+ return shell() !== null || /AmbasadaChat\//.test(w.navigator.userAgent);
13
+ }
14
+ function context() {
15
+ const s = shell();
16
+ return {
17
+ app: s?.app ?? null,
18
+ platform: s?.platform ?? "web",
19
+ safeArea: { ...DEFAULT_SAFE_AREA, ...s?.safeArea ?? {} },
20
+ hostBar: s?.hostBar ?? 0,
21
+ theme: s?.theme ?? { accent: "#FF002E", scheme: "light" },
22
+ locale: s?.locale ?? win()?.navigator.language ?? "en",
23
+ user: s?.user ?? null,
24
+ location: s?.location ?? { type: "launcher" }
25
+ };
26
+ }
27
+ function capabilities() {
28
+ const s = shell();
29
+ if (!s) return [];
30
+ if (s.capabilities) return s.capabilities;
31
+ return s.version >= 3 ? ["ready", "openUrl", "openApp", "close", "share", "haptics", "title", "auth.getToken", "notifications"] : ["openUrl", "close"];
32
+ }
33
+ var has = (c) => capabilities().includes(c);
34
+ function post(message) {
35
+ const w = win();
36
+ if (!w || !isInAmbasada()) return false;
37
+ if (w.webkit?.messageHandlers?.ambasada) w.webkit.messageHandlers.ambasada.postMessage(message);
38
+ else if (w.ambasada?.postMessage) w.ambasada.postMessage(JSON.stringify(message));
39
+ else return false;
40
+ return true;
41
+ }
42
+ var seq = 0;
43
+ function request(method, timeoutMs = 15e3) {
44
+ const w = win();
45
+ return new Promise((resolve, reject) => {
46
+ const id = `${Date.now().toString(36)}-${++seq}`;
47
+ const timer = setTimeout(() => {
48
+ w?.removeEventListener("ambasada:reply", onReply);
49
+ reject(new Error(`${method}: the Ambasada app did not answer`));
50
+ }, timeoutMs);
51
+ const onReply = (e) => {
52
+ const d = e.detail;
53
+ if (!d || d.id !== id) return;
54
+ clearTimeout(timer);
55
+ w?.removeEventListener("ambasada:reply", onReply);
56
+ if (d.error) reject(new Error(d.error));
57
+ else resolve(d.result);
58
+ };
59
+ w?.addEventListener("ambasada:reply", onReply);
60
+ if (!post({ type: "request", id, method })) {
61
+ clearTimeout(timer);
62
+ w?.removeEventListener("ambasada:reply", onReply);
63
+ reject(new Error(NOT_IN_AMBASADA));
64
+ }
65
+ });
66
+ }
67
+ function applyCssVars() {
68
+ const w = win();
69
+ if (!w) return;
70
+ const root = w.document.documentElement;
71
+ const ctx = context();
72
+ for (const side of ["top", "bottom", "left", "right"]) root.style.setProperty(`--ambasada-safe-area-${side}`, `${ctx.safeArea[side]}px`);
73
+ root.style.setProperty("--ambasada-host-bar", `${ctx.hostBar}px`);
74
+ root.classList.toggle("in-ambasada", isInAmbasada());
75
+ }
76
+ var readySent = false;
77
+ function ready() {
78
+ applyCssVars();
79
+ if (readySent) return;
80
+ readySent = true;
81
+ if (has("ready")) post({ type: "ready" });
82
+ else if (shell()?.version === 2) post({ type: "chrome", header: "hidden", tabs: "hidden", back: "none", statusBar: "dark" });
83
+ }
84
+ function openUrl(url) {
85
+ if (has("openUrl") && shell().version >= 3) post({ type: "openUrl", url });
86
+ else if (shell()?.version === 2) post({ type: "open", url, mode: "external" });
87
+ else win()?.open(url, "_blank", "noopener");
88
+ }
89
+ function openApp(appId, path = "/") {
90
+ if (has("openApp")) post({ type: "openApp", app: appId, path });
91
+ else win()?.open(`https://ambasada.id/a/${encodeURIComponent(appId)}${path.startsWith("/") ? path : `/${path}`}`, "_blank", "noopener");
92
+ }
93
+ function close() {
94
+ if (has("close")) post({ type: "close" });
95
+ else win()?.history.back();
96
+ }
97
+ async function share(data = {}) {
98
+ if (has("share")) {
99
+ post({ type: "share", ...data });
100
+ return;
101
+ }
102
+ const w = win();
103
+ if (w?.navigator.share) await w.navigator.share({ url: data.url ?? w.location.href, text: data.text }).catch(() => void 0);
104
+ }
105
+ var haptics = {
106
+ impact(style = "light") {
107
+ if (has("haptics")) post({ type: "haptic", kind: "impact", style });
108
+ },
109
+ notification(style = "success") {
110
+ if (has("haptics")) post({ type: "haptic", kind: "notification", style });
111
+ },
112
+ selection() {
113
+ if (has("haptics")) post({ type: "haptic", kind: "selection" });
114
+ }
115
+ };
116
+ function setTitle(title) {
117
+ const w = win();
118
+ if (w) w.document.title = title;
119
+ if (has("title")) post({ type: "title", title });
120
+ else if (shell()?.version === 2) post({ type: "chrome", title });
121
+ }
122
+ var auth = {
123
+ /**
124
+ * Silent sign-in (verified apps with the `identity` scope): a short-lived JWT (60 s, `aud` = this origin,
125
+ * `sub` = the Ambasada user id) to verify on your server against https://hub.ambasada.id/.well-known/jwks.json.
126
+ * Rejects outside the app or when the app is not verified: fall back to Sign in with Ambasada.
127
+ */
128
+ getToken() {
129
+ if (!has("auth.getToken")) return Promise.reject(new Error(has("ready") ? "silent sign-in is not available for this app" : NOT_IN_AMBASADA));
130
+ return request("auth.getToken");
131
+ },
132
+ /**
133
+ * Silent sign-in in one call: gets a shell token and POSTs it to your server (`/api/auth/shell` in the templates,
134
+ * `village.auth.shell(req)` in @ambasada/sdk), which sets your session cookie. Resolves the server's `user`, or
135
+ * null when the shell can't do it (outside the app, an older app, an app that is not verified): then show
136
+ * "Sign in with Ambasada". Rejects when your server refuses the token.
137
+ */
138
+ async signIn(options = {}) {
139
+ const w = win();
140
+ if (!w || !has("auth.getToken")) return null;
141
+ let token;
142
+ try {
143
+ token = (await request("auth.getToken")).token;
144
+ } catch {
145
+ return null;
146
+ }
147
+ const res = await w.fetch(options.endpoint ?? "/api/auth/shell", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ token }) });
148
+ if (!res.ok) throw new Error(`silent sign-in refused (${res.status})`);
149
+ return (await res.json()).user;
150
+ }
151
+ };
152
+ var notifications = {
153
+ /**
154
+ * Ask to send this person notifications. Call it from a tap. The shell shows its own sheet; on accept
155
+ * your webhook receives `notifications_enabled` with the token (docs/MINI-APPS.md §7).
156
+ * Outside the app, or without the capability: `{ enabled: false }`.
157
+ */
158
+ request() {
159
+ if (!has("notifications")) return Promise.resolve({ enabled: false });
160
+ return request("notifications.request");
161
+ }
162
+ };
163
+ function on(event, handler) {
164
+ const w = win();
165
+ if (!w) return () => void 0;
166
+ const listener = (e) => {
167
+ const detail = e.detail;
168
+ if (event === "context") {
169
+ if (detail && typeof detail === "object") Object.assign(w.ambasadaShell ?? (w.ambasadaShell = { version: 3, platform: "web" }), detail);
170
+ applyCssVars();
171
+ handler(context());
172
+ } else handler(detail);
173
+ };
174
+ w.addEventListener(`ambasada:${event}`, listener);
175
+ return () => w.removeEventListener(`ambasada:${event}`, listener);
176
+ }
177
+ function mockEnv(overrides = {}) {
178
+ const w = win();
179
+ if (!w) throw new Error("mockEnv needs a window");
180
+ const { version = 3, capabilities: caps, answers = {}, issuer, ...ctx } = overrides;
181
+ const messages = [];
182
+ w.ambasadaShell = {
183
+ version,
184
+ platform: "ios",
185
+ safeArea: { top: 59, bottom: 34, left: 0, right: 0 },
186
+ hostBar: 44,
187
+ theme: { accent: "#FF002E", scheme: "light" },
188
+ locale: "ro-RO",
189
+ app: { id: "my-company", tier: "unlisted" },
190
+ user: { id: "user_fake", name: "Fake Visitor", avatar: null },
191
+ location: { type: "launcher" },
192
+ ...caps ? { capabilities: caps } : {},
193
+ ...ctx
194
+ };
195
+ w.ambasada = {
196
+ postMessage(raw) {
197
+ const m = JSON.parse(raw);
198
+ messages.push(m);
199
+ if (m.type === "request") {
200
+ const reply = (a) => {
201
+ const detail = a instanceof Error ? { id: m.id, error: a.message } : { id: m.id, result: a };
202
+ setTimeout(() => w.dispatchEvent(new CustomEvent("ambasada:reply", { detail })), 0);
203
+ };
204
+ if (m.method in answers) reply(answers[m.method]);
205
+ else if (m.method === "auth.getToken" && issuer) {
206
+ w.fetch(`${issuer.replace(/\/+$/, "")}/dev/shell-token?aud=${encodeURIComponent(w.location.origin)}`).then((r) => r.ok ? r.json() : Promise.reject(new Error(`mock issuer answered ${r.status}`))).then(reply, reply);
207
+ } else reply(m.method === "auth.getToken" ? { token: "fake.jwt.token", expires_at: new Date(Date.now() + 6e4).toISOString() } : { enabled: true });
208
+ }
209
+ }
210
+ };
211
+ readySent = false;
212
+ applyCssVars();
213
+ const emit = (event, detail) => w.dispatchEvent(new CustomEvent(`ambasada:${event}`, { detail }));
214
+ const uninstall = () => {
215
+ delete w.ambasadaShell;
216
+ delete w.ambasada;
217
+ readySent = false;
218
+ applyCssVars();
219
+ };
220
+ return { messages, emit, uninstall };
221
+ }
222
+ var index_default = { shell, isInAmbasada, context, capabilities, applyCssVars, ready, openUrl, openApp, close, share, haptics, setTitle, auth, notifications, on, mockEnv };
223
+ export {
224
+ applyCssVars,
225
+ auth,
226
+ capabilities,
227
+ close,
228
+ context,
229
+ index_default as default,
230
+ haptics,
231
+ isInAmbasada,
232
+ mockEnv,
233
+ notifications,
234
+ on,
235
+ openApp,
236
+ openUrl,
237
+ ready,
238
+ setTitle,
239
+ share,
240
+ shell
241
+ };
@@ -0,0 +1,7 @@
1
+ "use strict";var __ambasadaMiniapp=(()=>{var x=Object.defineProperty;var O=Object.getOwnPropertyDescriptor;var F=Object.getOwnPropertyNames;var R=Object.prototype.hasOwnProperty;var D=(e,t)=>{for(var n in t)x(e,n,{get:t[n],enumerable:!0})},N=(e,t,n,a)=>{if(t&&typeof t=="object"||typeof t=="function")for(let s of F(t))!R.call(e,s)&&s!==n&&x(e,s,{get:()=>t[s],enumerable:!(a=O(t,s))||a.enumerable});return e};var W=e=>N(x({},"__esModule",{value:!0}),e);var z={};D(z,{applyCssVars:()=>f,auth:()=>M,capabilities:()=>E,close:()=>P,context:()=>v,default:()=>V,haptics:()=>L,isInAmbasada:()=>y,mockEnv:()=>B,notifications:()=>q,on:()=>j,openApp:()=>$,openUrl:()=>C,ready:()=>T,setTitle:()=>_,share:()=>U,shell:()=>c});var H={top:0,bottom:0,left:0,right:0},A="not inside the Ambasada app",i=()=>typeof window>"u"?null:window;function c(){let e=i()?.ambasadaShell;return e&&typeof e.version=="number"&&e.version>=2?e:null}function y(){let e=i();return e?c()!==null||/AmbasadaChat\//.test(e.navigator.userAgent):!1}function v(){let e=c();return{app:e?.app??null,platform:e?.platform??"web",safeArea:{...H,...e?.safeArea??{}},hostBar:e?.hostBar??0,theme:e?.theme??{accent:"#FF002E",scheme:"light"},locale:e?.locale??i()?.navigator.language??"en",user:e?.user??null,location:e?.location??{type:"launcher"}}}function E(){let e=c();return e?e.capabilities?e.capabilities:e.version>=3?["ready","openUrl","openApp","close","share","haptics","title","auth.getToken","notifications"]:["openUrl","close"]:[]}var o=e=>E().includes(e);function r(e){let t=i();if(!t||!y())return!1;if(t.webkit?.messageHandlers?.ambasada)t.webkit.messageHandlers.ambasada.postMessage(e);else if(t.ambasada?.postMessage)t.ambasada.postMessage(JSON.stringify(e));else return!1;return!0}var J=0;function k(e,t=15e3){let n=i();return new Promise((a,s)=>{let l=`${Date.now().toString(36)}-${++J}`,m=setTimeout(()=>{n?.removeEventListener("ambasada:reply",d),s(new Error(`${e}: the Ambasada app did not answer`))},t),d=S=>{let h=S.detail;!h||h.id!==l||(clearTimeout(m),n?.removeEventListener("ambasada:reply",d),h.error?s(new Error(h.error)):a(h.result))};n?.addEventListener("ambasada:reply",d),r({type:"request",id:l,method:e})||(clearTimeout(m),n?.removeEventListener("ambasada:reply",d),s(new Error(A)))})}function f(){let e=i();if(!e)return;let t=e.document.documentElement,n=v();for(let a of["top","bottom","left","right"])t.style.setProperty(`--ambasada-safe-area-${a}`,`${n.safeArea[a]}px`);t.style.setProperty("--ambasada-host-bar",`${n.hostBar}px`),t.classList.toggle("in-ambasada",y())}var b=!1;function T(){f(),!b&&(b=!0,o("ready")?r({type:"ready"}):c()?.version===2&&r({type:"chrome",header:"hidden",tabs:"hidden",back:"none",statusBar:"dark"}))}function C(e){o("openUrl")&&c().version>=3?r({type:"openUrl",url:e}):c()?.version===2?r({type:"open",url:e,mode:"external"}):i()?.open(e,"_blank","noopener")}function $(e,t="/"){o("openApp")?r({type:"openApp",app:e,path:t}):i()?.open(`https://ambasada.id/a/${encodeURIComponent(e)}${t.startsWith("/")?t:`/${t}`}`,"_blank","noopener")}function P(){o("close")?r({type:"close"}):i()?.history.back()}async function U(e={}){if(o("share")){r({type:"share",...e});return}let t=i();t?.navigator.share&&await t.navigator.share({url:e.url??t.location.href,text:e.text}).catch(()=>{})}var L={impact(e="light"){o("haptics")&&r({type:"haptic",kind:"impact",style:e})},notification(e="success"){o("haptics")&&r({type:"haptic",kind:"notification",style:e})},selection(){o("haptics")&&r({type:"haptic",kind:"selection"})}};function _(e){let t=i();t&&(t.document.title=e),o("title")?r({type:"title",title:e}):c()?.version===2&&r({type:"chrome",title:e})}var M={getToken(){return o("auth.getToken")?k("auth.getToken"):Promise.reject(new Error(o("ready")?"silent sign-in is not available for this app":A))},async signIn(e={}){let t=i();if(!t||!o("auth.getToken"))return null;let n;try{n=(await k("auth.getToken")).token}catch{return null}let a=await t.fetch(e.endpoint??"/api/auth/shell",{method:"POST",credentials:"same-origin",headers:{"content-type":"application/json"},body:JSON.stringify({token:n})});if(!a.ok)throw new Error(`silent sign-in refused (${a.status})`);return(await a.json()).user}},q={request(){return o("notifications")?k("notifications.request"):Promise.resolve({enabled:!1})}};function j(e,t){let n=i();if(!n)return()=>{};let a=s=>{let l=s.detail;e==="context"?(l&&typeof l=="object"&&Object.assign(n.ambasadaShell??(n.ambasadaShell={version:3,platform:"web"}),l),f(),t(v())):t(l)};return n.addEventListener(`ambasada:${e}`,a),()=>n.removeEventListener(`ambasada:${e}`,a)}function B(e={}){let t=i();if(!t)throw new Error("mockEnv needs a window");let{version:n=3,capabilities:a,answers:s={},issuer:l,...m}=e,d=[];return t.ambasadaShell={version:n,platform:"ios",safeArea:{top:59,bottom:34,left:0,right:0},hostBar:44,theme:{accent:"#FF002E",scheme:"light"},locale:"ro-RO",app:{id:"my-company",tier:"unlisted"},user:{id:"user_fake",name:"Fake Visitor",avatar:null},location:{type:"launcher"},...a?{capabilities:a}:{},...m},t.ambasada={postMessage(w){let p=JSON.parse(w);if(d.push(p),p.type==="request"){let g=u=>{let I=u instanceof Error?{id:p.id,error:u.message}:{id:p.id,result:u};setTimeout(()=>t.dispatchEvent(new CustomEvent("ambasada:reply",{detail:I})),0)};p.method in s?g(s[p.method]):p.method==="auth.getToken"&&l?t.fetch(`${l.replace(/\/+$/,"")}/dev/shell-token?aud=${encodeURIComponent(t.location.origin)}`).then(u=>u.ok?u.json():Promise.reject(new Error(`mock issuer answered ${u.status}`))).then(g,g):g(p.method==="auth.getToken"?{token:"fake.jwt.token",expires_at:new Date(Date.now()+6e4).toISOString()}:{enabled:!0})}}},b=!1,f(),{messages:d,emit:(w,p)=>t.dispatchEvent(new CustomEvent(`ambasada:${w}`,{detail:p})),uninstall:()=>{delete t.ambasadaShell,delete t.ambasada,b=!1,f()}}}var V={shell:c,isInAmbasada:y,context:v,capabilities:E,applyCssVars:f,ready:T,openUrl:C,openApp:$,close:P,share:U,haptics:L,setTitle:_,auth:M,notifications:q,on:j,mockEnv:B};return W(z);})();
2
+ (function (g) {
3
+ var prev = g.ambasada, api = Object.assign({}, __ambasadaMiniapp.default, __ambasadaMiniapp);
4
+ delete api.default;
5
+ if (prev && typeof prev.postMessage === 'function') api.postMessage = function (m) { return prev.postMessage(m); };
6
+ g.ambasada = api;
7
+ })(typeof window !== 'undefined' ? window : globalThis);
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@ambasada/miniapp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.6.0",
4
+ "description": "Run your web app inside the Ambasada app as a mini app: ready(), shell context, safe areas, open links, haptics, notifications. Zero dependencies; also a <script> build.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "devDependencies": {
23
+ "@types/node": "^22.20.5",
24
+ "esbuild": "^0.28.2",
25
+ "typescript": "~5.9.3",
26
+ "vitest": "^4.1.12"
27
+ },
28
+ "author": "CENTYSO (https://centyso.com)",
29
+ "homepage": "https://ambasada.id",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/ambasada/cli.git",
33
+ "directory": "packages/miniapp"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/ambasada/cli/issues"
37
+ },
38
+ "keywords": [
39
+ "ambasada",
40
+ "mini-app",
41
+ "webview"
42
+ ],
43
+ "scripts": {
44
+ "build": "node scripts/build.mjs",
45
+ "typecheck": "tsc -p .",
46
+ "test": "vitest run"
47
+ }
6
48
  }