minutework 0.1.19 → 0.1.21

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.
Files changed (71) hide show
  1. package/EXTERNAL_ALPHA.md +15 -4
  2. package/README.md +48 -6
  3. package/assets/claude-local/CLAUDE.md.template +56 -3
  4. package/assets/claude-local/bundle.json +4 -1
  5. package/assets/claude-local/skills/README.md +5 -1
  6. package/assets/claude-local/skills/app-pack-authoring/SKILL.md +57 -0
  7. package/assets/claude-local/skills/contract-first-public-intake/SKILL.md +22 -0
  8. package/assets/claude-local/skills/generated-workspace-architecture/SKILL.md +16 -2
  9. package/assets/claude-local/skills/published-web-and-mw-core-site/SKILL.md +12 -0
  10. package/assets/claude-local/skills/runtime-capability-inventory/SKILL.md +17 -0
  11. package/assets/claude-local/skills/schema-engine/SKILL.md +18 -0
  12. package/assets/claude-local/skills/shell-architecture/SKILL.md +3 -0
  13. package/assets/claude-local/skills/standalone-mobile-client/SKILL.md +105 -0
  14. package/assets/claude-local/skills/vuilder-discovery-output-contract/SKILL.md +136 -0
  15. package/assets/claude-local/skills/vuilder-public-site-authoring/SKILL.md +76 -0
  16. package/assets/templates/fastapi-sidecar/README.md +7 -1
  17. package/assets/templates/fastapi-sidecar/template.schema.json +4 -2
  18. package/assets/templates/mobile-app/.env.example +16 -0
  19. package/assets/templates/mobile-app/AGENTS.md +44 -0
  20. package/assets/templates/mobile-app/README.md +123 -0
  21. package/assets/templates/mobile-app/app/(app)/_layout.tsx +10 -0
  22. package/assets/templates/mobile-app/app/(app)/index.tsx +72 -0
  23. package/assets/templates/mobile-app/app/(auth)/login.tsx +91 -0
  24. package/assets/templates/mobile-app/app/_layout.tsx +15 -0
  25. package/assets/templates/mobile-app/app.json +31 -0
  26. package/assets/templates/mobile-app/babel.config.js +7 -0
  27. package/assets/templates/mobile-app/eas.json +24 -0
  28. package/assets/templates/mobile-app/expo-env.d.ts +5 -0
  29. package/assets/templates/mobile-app/metro.config.js +7 -0
  30. package/assets/templates/mobile-app/package.json +32 -0
  31. package/assets/templates/mobile-app/src/mw/client.ts +251 -0
  32. package/assets/templates/mobile-app/src/mw/contracts.ts +79 -0
  33. package/assets/templates/mobile-app/src/mw/endpoints.ts +42 -0
  34. package/assets/templates/mobile-app/src/mw/env.ts +59 -0
  35. package/assets/templates/mobile-app/src/mw/session.ts +50 -0
  36. package/assets/templates/mobile-app/template.json +18 -0
  37. package/assets/templates/mobile-app/tools/template/validate-template.mjs +69 -0
  38. package/assets/templates/mobile-app/tsconfig.json +16 -0
  39. package/assets/templates/next-tenant-app/README.md +15 -4
  40. package/assets/templates/next-tenant-app/template.schema.json +4 -2
  41. package/dist/cli-json.d.ts +28 -0
  42. package/dist/cli-json.js +20 -0
  43. package/dist/cli-json.js.map +1 -0
  44. package/dist/compile.js +71 -7
  45. package/dist/compile.js.map +1 -1
  46. package/dist/deploy.js +147 -14
  47. package/dist/deploy.js.map +1 -1
  48. package/dist/developer-client.d.ts +22 -0
  49. package/dist/developer-client.js +9 -0
  50. package/dist/developer-client.js.map +1 -1
  51. package/dist/index.js +43 -14
  52. package/dist/index.js.map +1 -1
  53. package/dist/init-prompt.js +10 -2
  54. package/dist/init-prompt.js.map +1 -1
  55. package/dist/init.d.ts +2 -1
  56. package/dist/init.js +59 -2
  57. package/dist/init.js.map +1 -1
  58. package/dist/publish.d.ts +29 -0
  59. package/dist/publish.js +254 -0
  60. package/dist/publish.js.map +1 -0
  61. package/dist/runtime-package.d.ts +3 -1
  62. package/dist/runtime-package.js +13 -0
  63. package/dist/runtime-package.js.map +1 -1
  64. package/dist/workspace-assets.js +37 -5
  65. package/dist/workspace-assets.js.map +1 -1
  66. package/dist/workspace-bootstrap.d.ts +1 -0
  67. package/dist/workspace-bootstrap.js.map +1 -1
  68. package/dist/workspace.js +2 -0
  69. package/dist/workspace.js.map +1 -1
  70. package/package.json +2 -2
  71. package/vendor/workspace-mcp/types.d.ts +10 -0
@@ -0,0 +1,24 @@
1
+ {
2
+ "//": "DEVELOPER-OWNED — replace freely. Only src/mw/ is MinuteWork substrate. Distribution is YOUR EAS pipeline, not `minutework deploy`.",
3
+ "cli": {
4
+ "version": ">= 12.0.0",
5
+ "appVersionSource": "remote"
6
+ },
7
+ "build": {
8
+ "development": {
9
+ "developmentClient": true,
10
+ "distribution": "internal"
11
+ },
12
+ "preview": {
13
+ "distribution": "internal",
14
+ "channel": "preview"
15
+ },
16
+ "production": {
17
+ "channel": "production",
18
+ "autoIncrement": true
19
+ }
20
+ },
21
+ "submit": {
22
+ "production": {}
23
+ }
24
+ }
@@ -0,0 +1,5 @@
1
+ /// <reference types="expo/types" />
2
+
3
+ // DEVELOPER-OWNED — replace freely. Only src/mw/ is MinuteWork substrate.
4
+ // NOTE: This file should not be edited and should be committed; Expo regenerates
5
+ // it. It gives `process.env.EXPO_PUBLIC_*` string typings for `src/mw/env.ts`.
@@ -0,0 +1,7 @@
1
+ // DEVELOPER-OWNED — replace freely. Only src/mw/ is MinuteWork substrate.
2
+ // Default Expo Metro config. Customize bundling/resolver here if you need to.
3
+ const { getDefaultConfig } = require("expo/metro-config");
4
+
5
+ const config = getDefaultConfig(__dirname);
6
+
7
+ module.exports = config;
@@ -0,0 +1,32 @@
1
+ {
2
+ "//": "DEVELOPER-OWNED — replace freely. Only src/mw/ is MinuteWork substrate.",
3
+ "name": "mobile-app",
4
+ "version": "0.1.0",
5
+ "private": true,
6
+ "main": "expo-router/entry",
7
+ "scripts": {
8
+ "start": "expo start",
9
+ "ios": "expo start --ios",
10
+ "android": "expo start --android",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "expo": "~52.0.0",
15
+ "expo-constants": "~17.0.0",
16
+ "expo-crypto": "~14.0.2",
17
+ "expo-linking": "~7.0.0",
18
+ "expo-router": "~4.0.0",
19
+ "expo-secure-store": "~14.0.0",
20
+ "expo-status-bar": "~2.0.0",
21
+ "expo-web-browser": "~14.0.0",
22
+ "react": "18.3.1",
23
+ "react-native": "0.76.0",
24
+ "react-native-safe-area-context": "4.12.0",
25
+ "react-native-screens": "~4.1.0",
26
+ "zod": "^3.23.8"
27
+ },
28
+ "devDependencies": {
29
+ "@types/react": "~18.3.12",
30
+ "typescript": "~5.6.0"
31
+ }
32
+ }
@@ -0,0 +1,251 @@
1
+ // MinuteWork substrate. Thin layer — do not put product UI/logic here.
2
+ //
3
+ // MinuteWork native token client. Implements the browser-assisted device flow
4
+ // against the SHIPPED platform native-token endpoints (`/api/v1/native/session/*`).
5
+ //
6
+ // Authentication is owned by the MinuteWork platform. This client only *obtains
7
+ // and uses* a platform-issued bearer token — there is NO JWT minting, NO password
8
+ // handling, NO local user table, and NO parallel auth stack here. Token plaintext
9
+ // is never logged.
10
+ //
11
+ // ----------------------------------------------------------------------------
12
+ // Device flow (browser-assisted authorization):
13
+ // ----------------------------------------------------------------------------
14
+ // 1. authorize(): generate a PKCE code_verifier + S256 code_challenge, open the
15
+ // platform authorize URL in a system browser (`expo-web-browser`) with the
16
+ // app's deep-link redirect_uri + code_challenge + an anti-forgery `state`.
17
+ // The platform authenticates the human and redirects back to redirect_uri
18
+ // with a short-lived single-use `code` (and the echoed `state`).
19
+ // 2. exchange(code, verifier): POST {code, code_verifier, redirect_uri} to
20
+ // `/token-exchange/`; the platform returns the access/refresh token pair.
21
+ // 3. store the pair in the device keychain via `mwSession.setTokens(...)`.
22
+ // 4. for every platform call, send `Authorization: Bearer <access>`. On a 401,
23
+ // POST the stored refresh token to `/refresh/`, persist the rotated pair,
24
+ // and retry once.
25
+ // 5. logout(): POST `/logout/` to revoke, then `mwSession.clearTokens()`.
26
+ //
27
+ // This is the DIRECT platform path — there is no tenant-app BFF and no
28
+ // server-owned cookie in front of the mobile client.
29
+
30
+ import * as Crypto from "expo-crypto";
31
+ import * as Linking from "expo-linking";
32
+ import * as WebBrowser from "expo-web-browser";
33
+
34
+ import {
35
+ nativeSessionSchema,
36
+ nativeTokenPairSchema,
37
+ type NativeSession,
38
+ type NativeTokenPair,
39
+ } from "@/mw/contracts";
40
+ import { platformNativeEndpoints } from "@/mw/endpoints";
41
+ import { mwSession } from "@/mw/session";
42
+
43
+ // The deep-link path the platform redirects back to. The scheme is read from
44
+ // `app.json` ("scheme") by expo-linking; the dev configures that scheme.
45
+ const REDIRECT_PATH = "auth/native-callback";
46
+
47
+ // Result of a single authorize round-trip: the one-time code plus the PKCE
48
+ // verifier that must be presented at exchange.
49
+ export interface NativeAuthorizationResult {
50
+ code: string;
51
+ codeVerifier: string;
52
+ redirectUri: string;
53
+ }
54
+
55
+ function base64UrlFromBytes(bytes: Uint8Array): string {
56
+ let binary = "";
57
+ for (const byte of bytes) {
58
+ binary += String.fromCharCode(byte);
59
+ }
60
+ // btoa is available in the Hermes/React Native runtime.
61
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
62
+ }
63
+
64
+ // PKCE code_verifier: 32 random bytes -> base64url (43 chars, no padding). This
65
+ // is URL-safe and whitespace-free, matching the platform's `.strip()`ed verifier
66
+ // and its 255-char cap.
67
+ function generateCodeVerifier(): string {
68
+ return base64UrlFromBytes(Crypto.getRandomBytes(32));
69
+ }
70
+
71
+ // S256 challenge: base64url(SHA-256(verifier)). Mirrors the platform's
72
+ // `build_pkce_code_challenge` (sha256 digest -> urlsafe base64 -> strip "=").
73
+ async function deriveCodeChallenge(codeVerifier: string): Promise<string> {
74
+ const digestBase64 = await Crypto.digestStringAsync(
75
+ Crypto.CryptoDigestAlgorithm.SHA256,
76
+ codeVerifier,
77
+ { encoding: Crypto.CryptoEncoding.BASE64 },
78
+ );
79
+ return digestBase64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
80
+ }
81
+
82
+ function buildAuthorizeUrl(params: {
83
+ redirectUri: string;
84
+ state: string;
85
+ codeChallenge: string;
86
+ }): string {
87
+ const url = new URL(platformNativeEndpoints.authorize);
88
+ url.searchParams.set("redirect_uri", params.redirectUri);
89
+ url.searchParams.set("state", params.state);
90
+ url.searchParams.set("code_challenge", params.codeChallenge);
91
+ return url.toString();
92
+ }
93
+
94
+ async function postJson(url: string, body: Record<string, unknown>): Promise<Response> {
95
+ return fetch(url, {
96
+ method: "POST",
97
+ headers: {
98
+ "Content-Type": "application/json",
99
+ Accept: "application/json",
100
+ },
101
+ body: JSON.stringify(body),
102
+ });
103
+ }
104
+
105
+ // Surface a platform error without leaking response internals. The platform
106
+ // returns `{detail: "..."}` for native auth failures.
107
+ async function readErrorDetail(response: Response, fallback: string): Promise<string> {
108
+ try {
109
+ const data = (await response.json()) as { detail?: unknown };
110
+ if (data && typeof data.detail === "string" && data.detail.length > 0) {
111
+ return data.detail;
112
+ }
113
+ } catch {
114
+ // ignore non-JSON bodies
115
+ }
116
+ return `${fallback} (HTTP ${response.status})`;
117
+ }
118
+
119
+ export const mwClient = {
120
+ // Step 1: open the platform authorize URL in a system browser and resolve with
121
+ // the single-use authorization code + the PKCE verifier to exchange it.
122
+ async authorize(): Promise<NativeAuthorizationResult> {
123
+ const redirectUri = Linking.createURL(REDIRECT_PATH);
124
+ const codeVerifier = generateCodeVerifier();
125
+ const codeChallenge = await deriveCodeChallenge(codeVerifier);
126
+ const state = base64UrlFromBytes(Crypto.getRandomBytes(16));
127
+
128
+ const authorizeUrl = buildAuthorizeUrl({ redirectUri, state, codeChallenge });
129
+ const result = await WebBrowser.openAuthSessionAsync(authorizeUrl, redirectUri);
130
+
131
+ if (result.type !== "success" || !result.url) {
132
+ throw new Error("Sign in was cancelled before the platform returned a code.");
133
+ }
134
+
135
+ const returned = new URL(result.url);
136
+ const returnedState = returned.searchParams.get("state");
137
+ if (returnedState !== state) {
138
+ // Anti-forgery: the platform must echo back the exact state we sent.
139
+ throw new Error("Sign in failed: callback state did not match the request.");
140
+ }
141
+ const code = returned.searchParams.get("code");
142
+ if (!code) {
143
+ throw new Error("Sign in failed: the platform callback did not include a code.");
144
+ }
145
+
146
+ return { code, codeVerifier, redirectUri };
147
+ },
148
+
149
+ // Step 2: exchange the authorization code for a platform-issued token pair and
150
+ // persist it to the device keychain.
151
+ async exchange(
152
+ code: string,
153
+ codeVerifier: string,
154
+ redirectUri: string,
155
+ ): Promise<NativeTokenPair> {
156
+ const response = await postJson(platformNativeEndpoints.tokenExchange, {
157
+ code,
158
+ code_verifier: codeVerifier,
159
+ redirect_uri: redirectUri,
160
+ });
161
+ if (!response.ok) {
162
+ throw new Error(await readErrorDetail(response, "Token exchange failed"));
163
+ }
164
+ const pair = nativeTokenPairSchema.parse(await response.json());
165
+ await mwSession.setTokens(
166
+ pair.access_token,
167
+ pair.refresh_token,
168
+ pair.access_token_expires_at,
169
+ );
170
+ return pair;
171
+ },
172
+
173
+ // Step 4 (on 401 / proactive): rotate the access token using the stored refresh
174
+ // token and persist the rotated pair.
175
+ async refresh(): Promise<NativeTokenPair> {
176
+ const stored = await mwSession.getTokens();
177
+ if (!stored) {
178
+ throw new Error("No stored session to refresh. Sign in again.");
179
+ }
180
+ const response = await postJson(platformNativeEndpoints.refresh, {
181
+ refresh_token: stored.refresh,
182
+ });
183
+ if (!response.ok) {
184
+ // Refresh failure means the session is dead; clear it so the app routes
185
+ // back to login.
186
+ await mwSession.clearTokens();
187
+ throw new Error(await readErrorDetail(response, "Session refresh failed"));
188
+ }
189
+ const pair = nativeTokenPairSchema.parse(await response.json());
190
+ await mwSession.setTokens(
191
+ pair.access_token,
192
+ pair.refresh_token,
193
+ pair.access_token_expires_at,
194
+ );
195
+ return pair;
196
+ },
197
+
198
+ // Resolve the active session (user + active tenant + memberships) from the
199
+ // stored token, auto-refreshing once on a 401 then retrying.
200
+ async loadSession(): Promise<NativeSession> {
201
+ const stored = await mwSession.getTokens();
202
+ if (!stored) {
203
+ throw new Error("Not signed in.");
204
+ }
205
+
206
+ const meOnce = async (accessToken: string): Promise<Response> =>
207
+ fetch(platformNativeEndpoints.me, {
208
+ method: "GET",
209
+ headers: {
210
+ Accept: "application/json",
211
+ Authorization: `Bearer ${accessToken}`,
212
+ },
213
+ });
214
+
215
+ let response = await meOnce(stored.access);
216
+ if (response.status === 401) {
217
+ // Single refresh-and-retry on an expired/invalid access token.
218
+ const rotated = await this.refresh();
219
+ response = await meOnce(rotated.access_token);
220
+ }
221
+ if (!response.ok) {
222
+ throw new Error(await readErrorDetail(response, "Failed to load session"));
223
+ }
224
+ return nativeSessionSchema.parse(await response.json());
225
+ },
226
+
227
+ // Step 5: revoke the token pair on the platform, then clear local secure
228
+ // storage. Local state is always cleared, even if the revoke call fails.
229
+ async logout(): Promise<void> {
230
+ const stored = await mwSession.getTokens();
231
+ try {
232
+ if (stored) {
233
+ await fetch(platformNativeEndpoints.logout, {
234
+ method: "POST",
235
+ headers: {
236
+ "Content-Type": "application/json",
237
+ Accept: "application/json",
238
+ Authorization: `Bearer ${stored.access}`,
239
+ },
240
+ body: JSON.stringify({}),
241
+ });
242
+ }
243
+ } catch {
244
+ // Best-effort revoke; never block local sign-out on a network error.
245
+ } finally {
246
+ await mwSession.clearTokens();
247
+ }
248
+ },
249
+ };
250
+
251
+ export type MwClient = typeof mwClient;
@@ -0,0 +1,79 @@
1
+ // MinuteWork substrate. Thin layer — do not put product UI/logic here.
2
+ //
3
+ // Zod schemas for the MinuteWork platform *native* session payloads. These match
4
+ // the SHIPPED platform native-token slice (`/api/v1/native/session/*`) response
5
+ // shapes exactly (DRF emits snake_case). They describe API responses, not product
6
+ // data. Keep them aligned with the platform serializers in
7
+ // `apps/mwv3-platform-dj/apps/gateway_api/serializers.py`:
8
+ // - token pair -> IssuedNativeAppSessionTokenPairSerializer
9
+ // - session/me -> TenantSessionResponseSerializer
10
+ //
11
+ // Schemas use `.passthrough()` so additive platform fields never break parsing;
12
+ // the client only depends on the fields it reads.
13
+
14
+ import { z } from "zod";
15
+
16
+ // A tenant the authenticated user belongs to. Mirrors `_serialize_membership`
17
+ // on the platform; only the load-bearing fields are constrained, the rest pass
18
+ // through. `role` may be an empty string for a membership with no role.
19
+ export const nativeMembershipSchema = z
20
+ .object({
21
+ tenant_id: z.string().min(1),
22
+ tenant_slug: z.string(),
23
+ tenant_name: z.string(),
24
+ role: z.string(),
25
+ })
26
+ .passthrough();
27
+
28
+ export const nativeActiveTenantSchema = nativeMembershipSchema;
29
+
30
+ // Mirrors `_serialize_user`. `email` is not constrained to an email shape because
31
+ // the platform may serialize a blank/non-email value.
32
+ export const nativeUserSchema = z
33
+ .object({
34
+ id: z.string().min(1),
35
+ username: z.string(),
36
+ email: z.string(),
37
+ })
38
+ .passthrough();
39
+
40
+ // Response of `/api/v1/native/session/me/` (and `/context/`):
41
+ // `TenantSessionResponseSerializer`. `active_tenant_id` / `active_tenant` are
42
+ // null when the user has no active membership.
43
+ export const nativeSessionSchema = z
44
+ .object({
45
+ user: nativeUserSchema,
46
+ active_tenant_id: z.string().nullable(),
47
+ active_tenant: nativeActiveTenantSchema.nullable(),
48
+ memberships: z.array(nativeMembershipSchema),
49
+ onboarding_completed_for_active_workspace: z.boolean().nullable().optional(),
50
+ })
51
+ .passthrough();
52
+
53
+ // Platform-issued bearer token pair returned by `/token-exchange/` and
54
+ // `/refresh/`: `IssuedNativeAppSessionTokenPairSerializer`. `*_expires_at` are
55
+ // ISO-8601 timestamps; the client uses the access expiry to refresh proactively.
56
+ export const nativeTokenPairSchema = z
57
+ .object({
58
+ access_token: z.string().min(1),
59
+ refresh_token: z.string().min(1),
60
+ access_token_expires_at: z.string().min(1),
61
+ refresh_token_expires_at: z.string().min(1),
62
+ })
63
+ .passthrough();
64
+
65
+ // What we persist in the device keychain: the two opaque tokens plus the access
66
+ // token's expiry (ISO-8601). This is the on-device shape, distinct from the wire
67
+ // shape above.
68
+ export const storedTokensSchema = z.object({
69
+ access: z.string().min(1),
70
+ refresh: z.string().min(1),
71
+ expiresAt: z.string().min(1),
72
+ });
73
+
74
+ export type NativeMembership = z.infer<typeof nativeMembershipSchema>;
75
+ export type NativeActiveTenant = z.infer<typeof nativeActiveTenantSchema>;
76
+ export type NativeUser = z.infer<typeof nativeUserSchema>;
77
+ export type NativeSession = z.infer<typeof nativeSessionSchema>;
78
+ export type NativeTokenPair = z.infer<typeof nativeTokenPairSchema>;
79
+ export type StoredTokens = z.infer<typeof storedTokensSchema>;
@@ -0,0 +1,42 @@
1
+ // MinuteWork substrate. Thin layer — do not put product UI/logic here.
2
+ //
3
+ // Builds absolute URLs for the MinuteWork *platform native* session endpoints.
4
+ //
5
+ // These endpoints are the DIRECT platform API surface for native clients
6
+ // (`/api/v1/native/...`). The mobile app authenticates with a platform-issued
7
+ // bearer token obtained through a browser-assisted device flow, then calls the
8
+ // platform directly. This is intentionally NOT the tenant-app BFF cookie path
9
+ // (the Next.js `platform_session_bff` profile) — there is no per-app server in
10
+ // front of the mobile client.
11
+ //
12
+ // These routes are SHIPPED by the platform native-token slice and are called by
13
+ // the real device-flow client in `src/mw/client.ts`.
14
+
15
+ import { mwEnv } from "@/mw/env";
16
+
17
+ const platformBaseUrl = new URL(
18
+ mwEnv.platformBaseUrl.endsWith("/")
19
+ ? mwEnv.platformBaseUrl
20
+ : `${mwEnv.platformBaseUrl}/`,
21
+ );
22
+
23
+ function buildPlatformEndpoint(path: string): string {
24
+ return new URL(path.replace(/^\//, ""), platformBaseUrl).toString();
25
+ }
26
+
27
+ export const platformNativeEndpoints = {
28
+ // Begin the browser-assisted device authorization flow.
29
+ authorize: buildPlatformEndpoint("/api/v1/native/session/authorize/"),
30
+ // Exchange the authorization result for an access/refresh token pair.
31
+ tokenExchange: buildPlatformEndpoint("/api/v1/native/session/token-exchange/"),
32
+ // Rotate an expired access token using the refresh token.
33
+ refresh: buildPlatformEndpoint("/api/v1/native/session/refresh/"),
34
+ // Resolve the current authenticated principal (user + active tenant).
35
+ me: buildPlatformEndpoint("/api/v1/native/session/me/"),
36
+ // Resolve / switch active tenant context for the session.
37
+ context: buildPlatformEndpoint("/api/v1/native/session/context/"),
38
+ // Revoke the current token pair.
39
+ logout: buildPlatformEndpoint("/api/v1/native/session/logout/"),
40
+ } as const;
41
+
42
+ export type PlatformNativeEndpoint = keyof typeof platformNativeEndpoints;
@@ -0,0 +1,59 @@
1
+ // MinuteWork substrate. Thin layer — do not put product UI/logic here.
2
+ //
3
+ // Validates the EXPO_PUBLIC_* configuration the mobile app needs to talk to the
4
+ // MinuteWork platform. Mirrors the spirit of the next-tenant-app
5
+ // `env.server.ts`, but for Expo public env: in Expo, only `EXPO_PUBLIC_*`
6
+ // variables are inlined into the client bundle, and they must be referenced as
7
+ // *static* `process.env.EXPO_PUBLIC_FOO` property accesses so the Expo bundler
8
+ // can replace them at build time. Do not read them via dynamic indexing.
9
+ //
10
+ // SECURITY: EXPO_PUBLIC_* values ship inside the app bundle and are readable by
11
+ // anyone with the binary. Only put non-secret configuration here (a base URL,
12
+ // a display name). Never put API keys, client secrets, or tokens in EXPO_PUBLIC_*.
13
+
14
+ import { z } from "zod";
15
+
16
+ const baseUrlSchema = z.preprocess((value) => {
17
+ if (typeof value !== "string") {
18
+ return value;
19
+ }
20
+ const normalized = value.trim();
21
+ return normalized.length > 0 ? normalized : undefined;
22
+ }, z.string().url());
23
+
24
+ const optionalNameSchema = z.preprocess((value) => {
25
+ if (typeof value !== "string") {
26
+ return value;
27
+ }
28
+ const normalized = value.trim();
29
+ return normalized.length > 0 ? normalized : undefined;
30
+ }, z.string().min(1).default("MinuteWork"));
31
+
32
+ const envSchema = z.object({
33
+ platformBaseUrl: baseUrlSchema,
34
+ appName: optionalNameSchema,
35
+ });
36
+
37
+ const parsed = envSchema.safeParse({
38
+ // Static accesses so the Expo bundler can inline these at build time.
39
+ platformBaseUrl: process.env.EXPO_PUBLIC_MW_PLATFORM_BASE_URL,
40
+ appName: process.env.EXPO_PUBLIC_MW_APP_NAME,
41
+ });
42
+
43
+ if (!parsed.success) {
44
+ const issues = parsed.error.issues
45
+ .map((issue) => {
46
+ const path = issue.path.join(".");
47
+ return path.length > 0 ? `${path}: ${issue.message}` : issue.message;
48
+ })
49
+ .join("; ");
50
+
51
+ throw new Error(
52
+ `Invalid Expo public environment for mobile-app: ${issues}. ` +
53
+ "Copy .env.example to .env and set EXPO_PUBLIC_MW_PLATFORM_BASE_URL.",
54
+ );
55
+ }
56
+
57
+ export type MwEnv = z.infer<typeof envSchema>;
58
+
59
+ export const mwEnv: MwEnv = parsed.data;
@@ -0,0 +1,50 @@
1
+ // MinuteWork substrate. Thin layer — do not put product UI/logic here.
2
+ //
3
+ // Secure-store wrapper for the platform-issued native token pair. Persists the
4
+ // `{access, refresh, expiresAt}` triple in the device keychain/keystore via
5
+ // `expo-secure-store` (JSON-encoded under a single key) so tokens survive app
6
+ // restarts. Tokens MUST NOT be written to AsyncStorage or JS-readable cookie
7
+ // storage, and their plaintext is never logged.
8
+
9
+ import * as SecureStore from "expo-secure-store";
10
+
11
+ import { storedTokensSchema, type StoredTokens } from "@/mw/contracts";
12
+
13
+ const TOKEN_KEY = "mw.native.token_pair";
14
+
15
+ // Require the device to be unlocked at least once after boot before the secret is
16
+ // readable, and keep it bound to this device (no iCloud Keychain sync).
17
+ const SECURE_STORE_OPTIONS: SecureStore.SecureStoreOptions = {
18
+ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
19
+ };
20
+
21
+ export const mwSession = {
22
+ // Read + validate the stored pair. Returns null when absent or corrupt; a
23
+ // corrupt blob is cleared so the app fails closed back to the login flow.
24
+ async getTokens(): Promise<StoredTokens | null> {
25
+ const raw = await SecureStore.getItemAsync(TOKEN_KEY, SECURE_STORE_OPTIONS);
26
+ if (!raw) {
27
+ return null;
28
+ }
29
+ try {
30
+ return storedTokensSchema.parse(JSON.parse(raw));
31
+ } catch {
32
+ // Do not surface token bytes; just discard the unreadable blob.
33
+ await SecureStore.deleteItemAsync(TOKEN_KEY, SECURE_STORE_OPTIONS);
34
+ return null;
35
+ }
36
+ },
37
+
38
+ // Persist the pair. `expiresAt` is the access token's ISO-8601 expiry.
39
+ async setTokens(access: string, refresh: string, expiresAt: string): Promise<void> {
40
+ const value = JSON.stringify({ access, refresh, expiresAt } satisfies StoredTokens);
41
+ await SecureStore.setItemAsync(TOKEN_KEY, value, SECURE_STORE_OPTIONS);
42
+ },
43
+
44
+ // Remove the stored pair (sign-out / revoke).
45
+ async clearTokens(): Promise<void> {
46
+ await SecureStore.deleteItemAsync(TOKEN_KEY, SECURE_STORE_OPTIONS);
47
+ },
48
+ };
49
+
50
+ export type MwSession = typeof mwSession;
@@ -0,0 +1,18 @@
1
+ {
2
+ "template_id": "mobile-app",
3
+ "template_kind": "mobile-app",
4
+ "template_profile": "platform_native_token",
5
+ "template_bundle_ref": "runtime/builder/templates/mobile-app",
6
+ "template_version": "0.1.0",
7
+ "materialize": {
8
+ "destination": "mobile"
9
+ },
10
+ "builder_edit_mode": "workspace_copy_only",
11
+ "seed_source": "runtime/builder/templates/mobile-app",
12
+ "required_bootstrap_steps": [],
13
+ "runtime_contract_refs": [
14
+ "reference/mwv3-dj6-docs/auth_and_credential_contract.md"
15
+ ],
16
+ "deployable": false,
17
+ "notes": "Bring-your-own-UI Expo (React Native + Expo Router) starter. Direct platform native API client (bearer token to /api/v1/native/...), NOT a tenant-app BFF cookie client. Distribution is the developer's EAS pipeline, not `minutework deploy`. Only src/mw/ is MinuteWork substrate; everything else is developer-owned. src/mw/client.ts implements the real browser-assisted PKCE device flow against the platform native session endpoints; the OAuth-style redirect target is the app.json expo.scheme. This manifest is validated by tools/template/validate-template.mjs, not the strict shared template.schema.json (mobile is not a web/sidecar deployable kind)."
18
+ }
@@ -0,0 +1,69 @@
1
+ // MinuteWork substrate — template governance tool (not part of the shipped app).
2
+ //
3
+ // Validates this template's `template.json`. The mobile starter is NOT a
4
+ // web/sidecar deployable, so it intentionally does NOT validate against the
5
+ // strict shared `runtime/builder/templates/template.schema.json` (whose
6
+ // `template_kind` / `materialize.destination` enums only cover web + sidecar).
7
+ // Instead it checks the required fields and the mobile-specific literals,
8
+ // mirroring the bespoke validator pattern used by `vuilder-public-site`.
9
+ //
10
+ // Run from this directory: node tools/template/validate-template.mjs
11
+ import { readFileSync } from "node:fs";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const here = path.dirname(fileURLToPath(import.meta.url));
16
+ const templateRoot = path.resolve(here, "..", "..");
17
+ const manifestPath = path.join(templateRoot, "template.json");
18
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
19
+
20
+ const requiredFields = [
21
+ "template_id",
22
+ "template_kind",
23
+ "template_profile",
24
+ "template_version",
25
+ "materialize",
26
+ "builder_edit_mode",
27
+ "seed_source",
28
+ "required_bootstrap_steps",
29
+ ];
30
+
31
+ for (const field of requiredFields) {
32
+ if (!(field in manifest)) {
33
+ throw new Error(`template.json is missing required field "${field}"`);
34
+ }
35
+ }
36
+
37
+ if (manifest.template_id !== "mobile-app") {
38
+ throw new Error('template_id must be "mobile-app"');
39
+ }
40
+
41
+ if (manifest.template_kind !== "mobile-app") {
42
+ throw new Error('template_kind must be "mobile-app"');
43
+ }
44
+
45
+ if (manifest.template_profile !== "platform_native_token") {
46
+ throw new Error('template_profile must be "platform_native_token"');
47
+ }
48
+
49
+ if (manifest.materialize?.destination !== "mobile") {
50
+ throw new Error('mobile-app must materialize to "mobile"');
51
+ }
52
+
53
+ if (manifest.builder_edit_mode !== "workspace_copy_only") {
54
+ throw new Error('builder_edit_mode must be "workspace_copy_only"');
55
+ }
56
+
57
+ if (manifest.deployable !== false) {
58
+ throw new Error("mobile-app must declare deployable: false (EAS pipeline, not `minutework deploy`)");
59
+ }
60
+
61
+ if (!Array.isArray(manifest.required_bootstrap_steps)) {
62
+ throw new Error("required_bootstrap_steps must be an array");
63
+ }
64
+
65
+ if (!/^\d+\.\d+\.\d+$/.test(String(manifest.template_version))) {
66
+ throw new Error("template_version must be semver (e.g. 0.1.0)");
67
+ }
68
+
69
+ console.log("template.json is valid (mobile-app)");
@@ -0,0 +1,16 @@
1
+ {
2
+ "//": "DEVELOPER-OWNED — replace freely. Only src/mw/ is MinuteWork substrate.",
3
+ "extends": "expo/tsconfig.base",
4
+ "compilerOptions": {
5
+ "strict": true,
6
+ "paths": {
7
+ "@/*": ["./src/*"]
8
+ }
9
+ },
10
+ "include": [
11
+ "**/*.ts",
12
+ "**/*.tsx",
13
+ ".expo/types/**/*.ts",
14
+ "expo-env.d.ts"
15
+ ]
16
+ }