@capuchoo/core 0.4.0 → 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/dist/index.d.ts +78 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +93 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -669,5 +669,82 @@ declare function canPublishTo(profile: UserProfile, cloudAppId: string): boolean
|
|
|
669
669
|
declare const APP_CREATOR_ROLES: ReadonlySet<string>;
|
|
670
670
|
declare function canCreateApps(organization: CloudOrganization): boolean;
|
|
671
671
|
//#endregion
|
|
672
|
-
|
|
672
|
+
//#region src/app-registration.d.ts
|
|
673
|
+
/**
|
|
674
|
+
* What happens when a bundle identifier is registered that already exists.
|
|
675
|
+
*
|
|
676
|
+
* `apps.app_id` is unique across the whole installation, so the second attempt
|
|
677
|
+
* hits a constraint rather than a permission check - and the caller cannot see
|
|
678
|
+
* the row that blocks them, because the listing is scoped to what they may
|
|
679
|
+
* access. Left as a raw 23505 that becomes a 500, the state is unreachable:
|
|
680
|
+
* the app cannot be created and cannot be seen.
|
|
681
|
+
*/
|
|
682
|
+
interface ExistingApp {
|
|
683
|
+
id: string;
|
|
684
|
+
app_id: string;
|
|
685
|
+
organization_id: string | null;
|
|
686
|
+
}
|
|
687
|
+
interface AppRegistrationFacts {
|
|
688
|
+
appId: string;
|
|
689
|
+
/** The organisation the caller asked to register in. */
|
|
690
|
+
requestedOrganizationId: string;
|
|
691
|
+
/** The row already holding this bundle id, if any. */
|
|
692
|
+
existing?: ExistingApp | null;
|
|
693
|
+
/** Whether `existing.organization_id` still resolves to a real organisation. */
|
|
694
|
+
existingOrganizationExists?: boolean;
|
|
695
|
+
/** Whether the caller already holds a direct app_permissions grant on it. */
|
|
696
|
+
callerHasDirectPermission?: boolean;
|
|
697
|
+
}
|
|
698
|
+
type AdoptionReason = "same-organisation" | "direct-permission" | "orphaned";
|
|
699
|
+
type AppRegistration = {
|
|
700
|
+
kind: "create";
|
|
701
|
+
} | {
|
|
702
|
+
kind: "adopt";
|
|
703
|
+
app: ExistingApp;
|
|
704
|
+
reason: AdoptionReason;
|
|
705
|
+
} | {
|
|
706
|
+
kind: "conflict";
|
|
707
|
+
app: ExistingApp;
|
|
708
|
+
};
|
|
709
|
+
/**
|
|
710
|
+
* Whether to insert, return the existing row, or refuse.
|
|
711
|
+
*
|
|
712
|
+
* Adoption is what makes `capuchoo init` idempotent, which the model requires:
|
|
713
|
+
* the identifier comes from the binary, so a second machine running init is
|
|
714
|
+
* describing the same app rather than asking for a new one.
|
|
715
|
+
*/
|
|
716
|
+
declare function decideAppRegistration(facts: AppRegistrationFacts): AppRegistration;
|
|
717
|
+
/** Whether an adopted row should be moved into the requested organisation. */
|
|
718
|
+
declare function adoptionReparents(reason: AdoptionReason): boolean;
|
|
719
|
+
declare function describeAppConflict(appId: string): string;
|
|
720
|
+
declare function describeAdoption(reason: AdoptionReason, appId: string): string;
|
|
721
|
+
//#endregion
|
|
722
|
+
//#region src/role-cap.d.ts
|
|
723
|
+
/** App roles, weakest first. Index is the ordering. */
|
|
724
|
+
declare const APP_ROLE_ORDER: readonly ["viewer", "tester", "developer", "admin"];
|
|
725
|
+
type AppRole = (typeof APP_ROLE_ORDER)[number];
|
|
726
|
+
declare function isAppRole(value: unknown): value is AppRole;
|
|
727
|
+
/** How much a role can do, for comparison only. */
|
|
728
|
+
declare function roleRank(role: AppRole): number;
|
|
729
|
+
/**
|
|
730
|
+
* The role a credential actually grants: the weaker of what the account has and
|
|
731
|
+
* what the key is capped at.
|
|
732
|
+
*
|
|
733
|
+
* A key is the account acting through a machine, so it can never grant more than
|
|
734
|
+
* the account has - and a cap lets it grant less, which is what makes a CI
|
|
735
|
+
* credential safe to hand out. An uncapped key (null) is the account's own role.
|
|
736
|
+
*/
|
|
737
|
+
declare function effectiveRole(accountRole: AppRole | null | undefined, keyCap?: AppRole | null): AppRole | null;
|
|
738
|
+
/**
|
|
739
|
+
* Whether a caller may mint a key capped at `requested`.
|
|
740
|
+
*
|
|
741
|
+
* A credential may never create one with more reach than itself, or a cap is not
|
|
742
|
+
* a boundary - a developer key could mint an admin key and escalate. A caller
|
|
743
|
+
* with no cap of its own (a dashboard session) may mint any.
|
|
744
|
+
*/
|
|
745
|
+
declare function canIssueCap(callerCap: AppRole | null | undefined, requested: AppRole | null | undefined): boolean;
|
|
746
|
+
/** One line describing what a cap allows, for a key listing. */
|
|
747
|
+
declare function describeCap(cap: AppRole | null | undefined): string;
|
|
748
|
+
//#endregion
|
|
749
|
+
export { APP_CREATOR_ROLES, APP_ROLE_ORDER, type AdoptionReason, type AppRegistration, type AppRegistrationFacts, type AppRole, type BuildConfig, type BumpType, type ChannelState, type CloudApp, type CloudChannel, type CloudOrganization, type CloudRelease, type CloudUser, type CredentialScope, DEFAULT_CHANNELS, type DeviceState, ENVIRONMENTS, type Environment, type EnvironmentSelection, type ExistingApp, type FlavourConfig, INITIAL_VERSION_CODES, type NativeRelease, type NativeUpdatePayload, type OtaRelease, PROJECT_CONFIG_VERSION, type Platform, type ProjectConfig, type RenderContext, type ResolvedProjectConfig, type ResolvedUpdate, type SemanticVersion, UPDATE_EVENTS, UPDATE_EVENT_REQUIRED, type UpdateCheckRequest, type UpdateCheckResponse, type UpdateDecision, type UpdateEvent, type UpdateEventPayload, type UpdateFacts, type UpdateKind, UpdateMessage, type UpdateMessageValue, type UpdateResponseKind, type UserProfile, type VersionCodes, adoptionReparents, bumpVersion, canCreateApps, canIssueCap, canPublishTo, compareVersions, decideAppRegistration, decideUpdate, defaultFlavour, describeAdoption, describeAppConflict, describeCap, describeDecision, describeEnvironmentMismatch, effectiveRole, environmentFromAppId, environmentMismatchWarning, formatVersion, hasEnvironmentMismatch, isAppRole, isBlockingResponse, isEnvironmentAllowed, isValidBundleId, nativePayload, nextVersionCode, normaliseProjectConfig, parseUpdateEvent, parseVersion, renderUpdateResponse, resolveUpdate, roleRank, suggestEnvironment, validateProjectConfig, versionEnv };
|
|
673
750
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/update-contract.ts","../src/channel-environment.ts","../src/update-decision.ts","../src/project-config.ts","../src/version.ts","../src/cloud.ts"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/update-contract.ts","../src/channel-environment.ts","../src/update-decision.ts","../src/project-config.ts","../src/version.ts","../src/cloud.ts","../src/app-registration.ts","../src/role-cap.ts"],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -595,6 +595,98 @@ function canCreateApps(organization) {
|
|
|
595
595
|
return APP_CREATOR_ROLES.has(organization.role);
|
|
596
596
|
}
|
|
597
597
|
//#endregion
|
|
598
|
-
|
|
598
|
+
//#region src/app-registration.ts
|
|
599
|
+
/**
|
|
600
|
+
* Whether to insert, return the existing row, or refuse.
|
|
601
|
+
*
|
|
602
|
+
* Adoption is what makes `capuchoo init` idempotent, which the model requires:
|
|
603
|
+
* the identifier comes from the binary, so a second machine running init is
|
|
604
|
+
* describing the same app rather than asking for a new one.
|
|
605
|
+
*/
|
|
606
|
+
function decideAppRegistration(facts) {
|
|
607
|
+
const { existing, requestedOrganizationId } = facts;
|
|
608
|
+
if (!existing) return { kind: "create" };
|
|
609
|
+
if (existing.organization_id === requestedOrganizationId) return {
|
|
610
|
+
kind: "adopt",
|
|
611
|
+
app: existing,
|
|
612
|
+
reason: "same-organisation"
|
|
613
|
+
};
|
|
614
|
+
if (facts.callerHasDirectPermission) return {
|
|
615
|
+
kind: "adopt",
|
|
616
|
+
app: existing,
|
|
617
|
+
reason: "direct-permission"
|
|
618
|
+
};
|
|
619
|
+
if (existing.organization_id === null || facts.existingOrganizationExists === false) return {
|
|
620
|
+
kind: "adopt",
|
|
621
|
+
app: existing,
|
|
622
|
+
reason: "orphaned"
|
|
623
|
+
};
|
|
624
|
+
return {
|
|
625
|
+
kind: "conflict",
|
|
626
|
+
app: existing
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
/** Whether an adopted row should be moved into the requested organisation. */
|
|
630
|
+
function adoptionReparents(reason) {
|
|
631
|
+
return reason === "orphaned";
|
|
632
|
+
}
|
|
633
|
+
function describeAppConflict(appId) {
|
|
634
|
+
return `${appId} is already registered to another organisation. Bundle identifiers are unique across Capuchoo, because a device reports only the id compiled into it. Ask whoever owns it to release it, or change the applicationId.`;
|
|
635
|
+
}
|
|
636
|
+
function describeAdoption(reason, appId) {
|
|
637
|
+
switch (reason) {
|
|
638
|
+
case "same-organisation": return `${appId} already exists in this organisation - linking to it.`;
|
|
639
|
+
case "direct-permission": return `${appId} already exists and you have access to it - linking to it.`;
|
|
640
|
+
case "orphaned": return `${appId} existed without an owning organisation - claiming it.`;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
//#endregion
|
|
644
|
+
//#region src/role-cap.ts
|
|
645
|
+
/** App roles, weakest first. Index is the ordering. */
|
|
646
|
+
const APP_ROLE_ORDER = [
|
|
647
|
+
"viewer",
|
|
648
|
+
"tester",
|
|
649
|
+
"developer",
|
|
650
|
+
"admin"
|
|
651
|
+
];
|
|
652
|
+
function isAppRole(value) {
|
|
653
|
+
return typeof value === "string" && APP_ROLE_ORDER.includes(value);
|
|
654
|
+
}
|
|
655
|
+
/** How much a role can do, for comparison only. */
|
|
656
|
+
function roleRank(role) {
|
|
657
|
+
return APP_ROLE_ORDER.indexOf(role);
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* The role a credential actually grants: the weaker of what the account has and
|
|
661
|
+
* what the key is capped at.
|
|
662
|
+
*
|
|
663
|
+
* A key is the account acting through a machine, so it can never grant more than
|
|
664
|
+
* the account has - and a cap lets it grant less, which is what makes a CI
|
|
665
|
+
* credential safe to hand out. An uncapped key (null) is the account's own role.
|
|
666
|
+
*/
|
|
667
|
+
function effectiveRole(accountRole, keyCap) {
|
|
668
|
+
if (!accountRole) return null;
|
|
669
|
+
if (!keyCap) return accountRole;
|
|
670
|
+
return roleRank(keyCap) < roleRank(accountRole) ? keyCap : accountRole;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Whether a caller may mint a key capped at `requested`.
|
|
674
|
+
*
|
|
675
|
+
* A credential may never create one with more reach than itself, or a cap is not
|
|
676
|
+
* a boundary - a developer key could mint an admin key and escalate. A caller
|
|
677
|
+
* with no cap of its own (a dashboard session) may mint any.
|
|
678
|
+
*/
|
|
679
|
+
function canIssueCap(callerCap, requested) {
|
|
680
|
+
if (!callerCap) return true;
|
|
681
|
+
if (!requested) return false;
|
|
682
|
+
return roleRank(requested) <= roleRank(callerCap);
|
|
683
|
+
}
|
|
684
|
+
/** One line describing what a cap allows, for a key listing. */
|
|
685
|
+
function describeCap(cap) {
|
|
686
|
+
if (!cap) return "the account's own rights";
|
|
687
|
+
return cap === "admin" || cap === "developer" ? `${cap} - may publish` : `${cap} - may not publish`;
|
|
688
|
+
}
|
|
689
|
+
//#endregion
|
|
690
|
+
export { APP_CREATOR_ROLES, APP_ROLE_ORDER, DEFAULT_CHANNELS, ENVIRONMENTS, INITIAL_VERSION_CODES, PROJECT_CONFIG_VERSION, UPDATE_EVENTS, UPDATE_EVENT_REQUIRED, UpdateMessage, adoptionReparents, bumpVersion, canCreateApps, canIssueCap, canPublishTo, compareVersions, decideAppRegistration, decideUpdate, defaultFlavour, describeAdoption, describeAppConflict, describeCap, describeDecision, describeEnvironmentMismatch, effectiveRole, environmentFromAppId, environmentMismatchWarning, formatVersion, hasEnvironmentMismatch, isAppRole, isBlockingResponse, isEnvironmentAllowed, isValidBundleId, nativePayload, nextVersionCode, normaliseProjectConfig, parseUpdateEvent, parseVersion, renderUpdateResponse, resolveUpdate, roleRank, suggestEnvironment, validateProjectConfig, versionEnv };
|
|
599
691
|
|
|
600
692
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/channel-environment.ts","../src/update-contract.ts","../src/project-config.ts","../src/version.ts","../src/update-decision.ts","../src/cloud.ts"],"sourcesContent":["import type { Environment } from \"./update-contract.js\";\n\n/**\n * A channel's `environment` decides which `.env` flavour the CLI builds and which\n * bundles the backend serves to it. The channel's *name* is only an identifier.\n *\n * Nothing links the two, so a channel named `prod` left on the `staging`\n * environment silently serves staging bundles to production devices - which is\n * how all three of Lowmaro's channels ended up on staging. These helpers make\n * the mismatch visible; they never correct it silently, because prod apps\n * legitimately point at a staging channel for beta testing.\n */\n\n/** Empty means \"not chosen yet\". A channel must not default into an environment. */\nexport type EnvironmentSelection = Environment | \"\";\n\nconst PATTERNS: Array<[Environment, RegExp]> = [\n [\"prod\", /^(prod|production|live|release|stable|main|master)$/],\n [\"staging\", /^(staging|stage|beta|uat|qa|test|preprod|pre-prod)$/],\n [\"dev\", /^(dev|develop|development|debug|local|alpha)$/],\n];\n\n/**\n * The environment a channel name implies, or `null` when the name says nothing.\n *\n * Matching is deliberately whole-name: a channel called `prod-eu` could belong\n * to either, and guessing at substrings would put a warning on names it cannot\n * reason about.\n */\nexport function suggestEnvironment(name: string): Environment | null {\n const normalized = name.trim().toLowerCase();\n if (!normalized) return null;\n\n for (const [environment, pattern] of PATTERNS) {\n if (pattern.test(normalized)) return environment;\n }\n\n return null;\n}\n\n/** True when the name implies one environment and a different one is selected. */\nexport function hasEnvironmentMismatch(name: string, environment: EnvironmentSelection): boolean {\n if (!environment) return false;\n\n const suggested = suggestEnvironment(name);\n return suggested !== null && suggested !== environment;\n}\n\n/** The warning to show for a mismatch, or `null` when there is nothing to warn about. */\nexport function environmentMismatchWarning(\n name: string,\n environment: EnvironmentSelection,\n): string | null {\n if (!hasEnvironmentMismatch(name, environment)) return null;\n\n const label = name.trim();\n return (\n `A channel named \"${label}\" is set to the ${environment} environment. ` +\n `Devices on it will receive ${environment} bundles, built from .env.${environment}. ` +\n `Set the environment to ${suggestEnvironment(label)} unless that is deliberate.`\n );\n}\n","/**\n * The wire contract for `POST {endpoint}/api/update`.\n *\n * This file is the single definition shared by the backend that produces the\n * response, the app runtime that consumes it, and the CLI that publishes the\n * artefacts it points at. Before this package existed the three had drifted:\n * the app template asked a second endpoint (`GET /api/native-updates/check`)\n * with an `{ available, update }` envelope the backend never returns on the\n * primary path, and sent a hard-coded `version_name: \"builtin\"` so the server\n * always compared against 0.0.0.\n */\n\nexport type Platform = \"android\" | \"ios\" | \"web\";\n\n/** Deployment environments. A channel is bound to exactly one of these. */\nexport type Environment = \"dev\" | \"staging\" | \"prod\";\n\n/**\n * Messages the backend puts in `message`. Both sides must agree on the exact\n * strings, so they live here rather than being retyped at each call site.\n */\nexport const UpdateMessage = {\n /** A newer native binary is assigned to the channel and must be installed. */\n NATIVE_UPDATE_REQUIRED: \"native_update_required\",\n /** A newer artefact is available. */\n UPDATE_AVAILABLE: \"update_available\",\n /**\n * A newer native binary is available, but not mandatory.\n *\n * Distinct from UPDATE_AVAILABLE because the top-level `url` is deliberately\n * absent: that field is the Capacitor plugin's OTA contract, and it\n * auto-downloads whatever is there and unzips it. An APK in `url` made the\n * plugin download 45 MB and fail, hiding the real update behind a download\n * error. The binary is in `native_update` instead.\n */\n NATIVE_UPDATE_AVAILABLE: \"native_update_available\",\n /** The device already runs the newest artefact for its channel. */\n NO_UPDATE: \"No update available\",\n /** No application carries the requesting bundle identifier. */\n APP_NOT_FOUND: \"App not found\",\n /** The channel name does not exist for this application. */\n CHANNEL_NOT_FOUND: \"Channel not found\",\n /**\n * The requesting app id does not belong to the channel's environment - a\n * staging build asking a production channel, for example.\n */\n ENVIRONMENT_MISMATCH: \"Environment mismatch\",\n /**\n * The channel exists but points at no bundle, and PLATFORM_MISMATCH means it\n * points at one built for another platform.\n *\n * Neither is actionable by the device, and both used to return a bare\n * `{ config: {} }` - the same response as \"you are up to date\". Three\n * different situations were indistinguishable on the wire, which is why an\n * iOS device asking an Android-only channel produced silence rather than a\n * diagnosis. Clients still take no action; the names exist so the answer to\n * \"why did nothing happen\" is in the response.\n */\n NO_BUNDLE: \"No bundle assigned\",\n PLATFORM_MISMATCH: \"Platform mismatch\",\n} as const;\n\nexport type UpdateMessageValue = (typeof UpdateMessage)[keyof typeof UpdateMessage];\n\n/**\n * How the plugin classifies a response that carries no downloadable bundle.\n *\n * Read from `@capgo/capacitor-updater@7.50.2`, which is the authority here:\n * `CapacitorUpdaterPlugin.normalizedUpdateResponseKind` (android, line 4333)\n * maps anything that is not one of these three to `\"failed\"`, and the check\n * path at line 4515 enters this branch whenever the response has *either* an\n * `error` or a `kind` key.\n *\n * Two consequences the backend must respect, both of which it violated:\n *\n * 1. A response that carries an update must NOT set `kind`, or the plugin\n * classifies it instead of downloading it.\n * 2. A response that carries no update MUST set `kind`, or it is reported as a\n * failed update check - which is where the app's \"the update could not be\n * downloaded\" came from on a device that was simply up to date.\n */\nexport type UpdateResponseKind = \"up_to_date\" | \"blocked\" | \"failed\";\n\n/** What the device tells the server about itself. */\nexport interface UpdateCheckRequest {\n /** Bundle identifier of the running build, e.g. `com.ayb.lowmaro.staging`. */\n appId: string;\n platform: Platform;\n /** Channel to consult. Falls back to `defaultChannel` server-side. */\n channel?: string;\n defaultChannel?: string;\n /**\n * Native build number as a string. The server compares this against\n * `native_updates.version_code` and against an OTA bundle's\n * `min_update_version`, so an omitted or wrong value silently disables\n * native-update gating.\n */\n versionCode?: string;\n /** Historical alias for `versionCode`; the server accepts either. */\n versionBuild?: string;\n /**\n * Semantic version of the *currently applied web bundle*, or `\"builtin\"`\n * when the app still runs the bundle shipped inside the binary. Sending a\n * constant here defeats version comparison entirely.\n */\n version_name?: string;\n /** Stable per-install identifier, used for channel overrides and stats. */\n deviceId?: string;\n isProd?: boolean;\n /**\n * Device facts the server stores but does not decide with. All optional: an\n * app that cannot determine one should omit it rather than send a placeholder,\n * because the server writes only the keys it receives and a placeholder would\n * overwrite a better value recorded earlier.\n */\n versionOs?: string;\n pluginVersion?: string;\n /**\n * Bundle version compiled into the binary. `version_name` is the *applied*\n * OTA bundle and is absent until one lands, so the two together are what say\n * whether a device has ever taken an update.\n */\n versionBuiltin?: string;\n isEmulator?: boolean;\n /** Caller-supplied label for this install, shown in the dashboard. */\n customId?: string;\n}\n\n/** A native binary (APK/IPA) the device should install. */\nexport interface NativeUpdatePayload {\n version_name: string;\n version_code: number;\n download_url: string;\n release_notes?: string;\n required?: boolean;\n platform?: Platform;\n /**\n * Size in bytes, so a client can warn before spending someone's mobile data\n * on 45 MB. The column is `file_size_bytes`; the two were never mapped, so\n * this was declared here and never once populated until `nativePayload`\n * translated it.\n */\n file_size?: number;\n}\n\n/**\n * The response. Every field is optional because the server returns a partial\n * object per outcome rather than a discriminated union - `resolveUpdate` below\n * narrows it into something a caller can branch on safely.\n */\nexport interface UpdateCheckResponse {\n message?: string;\n error?: string;\n\n /**\n * Classification for a response that carries no bundle. Absent - and it must\n * be absent - when one is offered. See `UpdateResponseKind`.\n */\n kind?: UpdateResponseKind;\n\n /** OTA bundle fields. */\n version_name?: string;\n /**\n * The same value as `version_name`, under the name the Capacitor plugin\n * reads.\n *\n * `CapacitorUpdaterPlugin` line 4551 calls `jsRes.getString(\"version\")`\n * unconditionally once a response is not classified, and a missing key throws\n * a JSONException that is caught as \"error in update check\". The backend sent\n * only `version_name`, so every background check the plugin made - on every\n * response, including a perfectly good bundle - ended as a failed update. Our\n * own runtime never noticed because it reads the response itself.\n */\n version?: string;\n url?: string;\n checksum?: string;\n sessionKey?: string;\n release_notes?: string;\n required?: boolean;\n\n /** Present when a native binary supersedes, or blocks, the OTA bundle. */\n native_update?: NativeUpdatePayload | null;\n\n /** Remote configuration resolved for the channel's environment. */\n config?: Record<string, string>;\n}\n\nexport type UpdateKind = \"native\" | \"ota\";\n\n/** A resolved, actionable update. */\nexport interface ResolvedUpdate {\n kind: UpdateKind;\n version: string;\n versionCode?: number;\n downloadUrl?: string;\n releaseNotes?: string;\n required: boolean;\n platform?: Platform;\n checksum?: string;\n sessionKey?: string;\n /**\n * Size in bytes of a native binary, when the server published one.\n *\n * The runtime verifies a cached download against it before reusing the file:\n * a connection dropped mid-download leaves a partial APK at the right path,\n * and installing that fails with \"There was a problem parsing the package\".\n */\n fileSize?: number;\n /** Set once the OTA plugin has downloaded the bundle. */\n bundleId?: string;\n}\n\n/**\n * Narrows a raw response into an update to act on, or `null` when there is\n * nothing to do.\n *\n * Native wins over OTA. The server can return both - a required native binary\n * alongside the OTA bundle that needs it - and installing the bundle first\n * would leave the device on a binary too old to run it.\n */\nexport function resolveUpdate(\n response: UpdateCheckResponse | null | undefined,\n): ResolvedUpdate | null {\n if (!response) return null;\n\n const native = response.native_update;\n if (native?.download_url) {\n return {\n kind: \"native\",\n version: native.version_name,\n versionCode: native.version_code,\n downloadUrl: native.download_url,\n releaseNotes: native.release_notes,\n // A native update is mandatory when the server says the OTA bundle\n // cannot run without it, whatever the record's own flag says.\n required:\n response.message === UpdateMessage.NATIVE_UPDATE_REQUIRED || (native.required ?? false),\n platform: native.platform,\n ...(typeof native.file_size === \"number\" ? { fileSize: native.file_size } : {}),\n };\n }\n\n if (response.url && response.version_name) {\n return {\n kind: \"ota\",\n version: response.version_name,\n downloadUrl: response.url,\n releaseNotes: response.release_notes,\n required: response.required ?? false,\n checksum: response.checksum,\n sessionKey: response.sessionKey,\n };\n }\n\n return null;\n}\n\n/**\n * True when the response reports a condition the user cannot fix by updating.\n * Callers should surface these instead of showing \"you are up to date\".\n */\nexport function isBlockingResponse(response: UpdateCheckResponse): boolean {\n return (\n response.message === UpdateMessage.CHANNEL_NOT_FOUND ||\n response.message === UpdateMessage.ENVIRONMENT_MISMATCH\n );\n}\n\n/**\n * Analytics events posted to `POST {endpoint}/api/native-updates/log`.\n *\n * A list rather than a bare union because `native_update_logs.event` carries a\n * CHECK constraint, and the two had drifted: the column allowed `check`,\n * `download`, `install`, `fail` and `skip` while this declared `check`,\n * `download`, `download_complete`, `install`, `cancel` and `error`. Three of\n * the six were rejected by the database, so a device reporting\n * `download_complete` - which is what one does after every native download -\n * got a 500. `native-update-events.test.ts` reads the migration and fails if\n * this list ever moves ahead of it again.\n */\nexport const UPDATE_EVENTS = [\n \"check\",\n \"download\",\n \"download_complete\",\n \"install\",\n \"cancel\",\n \"error\",\n] as const;\n\nexport type UpdateEvent = (typeof UPDATE_EVENTS)[number];\n\nexport interface UpdateEventPayload {\n event: UpdateEvent;\n platform: Platform;\n /**\n * Bundle identifier of the running build.\n *\n * `native_update_logs.app_id` is NOT NULL and the server cannot resolve a row\n * without it, so it rejects a payload that omits this with a 400. This field\n * was missing from the contract and from the app runtime, so **every** native\n * download, install and error event was rejected - and because the runtime\n * catches the failure and warns, nothing ever surfaced. It was found by\n * reading the WebView console on a device mid-install.\n */\n app_id: string;\n device_id: string;\n current_version_code: number;\n new_version?: string;\n new_version_code?: number;\n channel: string;\n environment: string;\n /** Failure detail for an `error` event. Older servers read `error_message`. */\n error?: string;\n}\n\n/** Field names a payload must carry for the server to record it. */\nexport const UPDATE_EVENT_REQUIRED = [\"event\", \"platform\", \"app_id\"] as const;\n\n/**\n * Validates an incoming update event, naming everything that is missing.\n *\n * Pure, and shared with the server, so \"what the client sends\" and \"what the\n * server accepts\" cannot drift the way they did here: the client sent `error`\n * and the server read `error_message`, so even a payload that got past\n * validation lost its failure detail.\n */\nexport function parseUpdateEvent(\n body: Record<string, unknown>,\n): { ok: true; event: UpdateEventPayload } | { ok: false; missing: string[] } {\n // Accepted under either name, so an app built against an older contract still\n // records rather than having its events silently dropped.\n const appId = (body.app_id ?? body.appId) as string | undefined;\n\n const missing: string[] = UPDATE_EVENT_REQUIRED.filter((field) =>\n field === \"app_id\" ? !appId : !body[field],\n );\n\n if (missing.length > 0) return { ok: false, missing };\n\n return {\n ok: true,\n event: {\n event: body.event as UpdateEvent,\n platform: body.platform as Platform,\n app_id: appId as string,\n device_id: (body.device_id ?? \"\") as string,\n current_version_code: Number(body.current_version_code ?? 0),\n new_version: body.new_version as string | undefined,\n new_version_code:\n body.new_version_code === undefined ? undefined : Number(body.new_version_code),\n channel: (body.channel ?? \"\") as string,\n environment: (body.environment ?? \"\") as string,\n error: (body.error ?? body.error_message) as string | undefined,\n },\n };\n}\n","import type { Environment } from \"./update-contract.js\";\n\n/**\n * `.capuchoo/project.json` - the file that makes an application deployable.\n *\n * Version 1 held only the cloud identifiers, so the CLI had to *guess* how to\n * build: it shelled out to `pnpm run assets:<env>`, `pnpm build:<env>`,\n * `pnpm trapeze:<env>` and `pnpm exec cap sync`. Any application that named\n * its scripts differently, used npm, or had not installed Trapeze simply\n * failed halfway through a deploy.\n *\n * Version 2 describes the *inputs* instead - where each flavour's env file,\n * Trapeze config and icon sources live - and lets the CLI own the execution.\n * Every field has a default, so a v1 file keeps working: `normaliseProjectConfig`\n * fills in the conventional layout both existing apps already use.\n */\nexport const PROJECT_CONFIG_VERSION = 2;\n\n/** One build flavour, keyed by the environment its channels are bound to. */\nexport interface FlavourConfig {\n /**\n * Env file supplying `VITE_APP_ID`, `VITE_APP_NAME`, `VITE_UPDATE_CHANNEL`\n * and friends. The CLI reads it and passes the values to the build and to\n * the native configuration step as environment variables - it does not\n * rewrite the file, so a deploy leaves the working tree clean.\n */\n envFile: string;\n /** Trapeze config applied to the native projects. Optional. */\n trapezeConfig?: string;\n /** Directory holding `icon.png` / `splash.png` for icon generation. */\n assetPath?: string;\n /** Vite `--mode`. Defaults to the flavour name. */\n mode?: string;\n}\n\nexport interface BuildConfig {\n /**\n * Overrides the web build. Leave unset and the CLI runs the project's own\n * Vite build through the workspace toolchain it detects.\n */\n command?: string;\n /** Where to run the build from, relative to the app. For monorepo roots. */\n cwd?: string;\n}\n\nexport interface ProjectConfig {\n /** Absent on v1 files. */\n version?: number;\n\n /** Bundle identifier of the production flavour. */\n appId: string;\n /** Primary key of the application in Capuchoo. */\n cloudAppId: string;\n appName: string;\n createdAt: string;\n\n /** Vite output directory, and what Capacitor copies into the native app. */\n webDir?: string;\n androidDir?: string;\n iosDir?: string;\n\n /**\n * Monotonic native build numbers per environment. Written by the CLI, and\n * the one file a deploy is expected to modify.\n */\n versionCodeFile?: string;\n\n flavours?: Partial<Record<Environment, FlavourConfig>>;\n build?: BuildConfig;\n\n /** Optional GitHub Pages mirror for generated web assets. */\n ghPagesRepo?: string;\n\n /** @deprecated v1 fields, folded into `build` by `normaliseProjectConfig`. */\n monorepoRoot?: string;\n /** @deprecated v1 field. */\n packageName?: string;\n}\n\n/** A `ProjectConfig` with every optional resolved. */\nexport interface ResolvedProjectConfig {\n version: number;\n appId: string;\n cloudAppId: string;\n appName: string;\n createdAt: string;\n webDir: string;\n androidDir: string;\n iosDir: string;\n versionCodeFile: string;\n flavours: Record<Environment, FlavourConfig>;\n build: BuildConfig;\n ghPagesRepo?: string;\n}\n\nexport const ENVIRONMENTS: readonly Environment[] = [\"dev\", \"staging\", \"prod\"];\n\n/**\n * The channels every app gets when it is created.\n *\n * A channel and an environment are different axes, and conflating them is\n * confusing enough to be worth writing down here:\n *\n * - **environment** is a safety gate. It decides which *builds* may be served\n * a channel - a `.staging` bundle id cannot be handed a prod channel - so it\n * exists to stop a staging bundle reaching production devices.\n * - **channel** is an audience. It decides which release a group of devices\n * receives.\n *\n * These three are the *pipeline*, one per environment, and they are what an app\n * needs before anything can be deployed to it at all. A new app had none, so the\n * first deploy failed on a channel/environment pairing the user had to reason out\n * from an error message.\n *\n * Anything beyond these is an *audience*, not a stage: a per-client channel or\n * an A/B arm is an extra channel on the **prod** environment, because the people\n * on it are running production builds. It is not another set of three.\n */\nexport const DEFAULT_CHANNELS: ReadonlyArray<{ name: string; environment: Environment }> = [\n { name: \"prod\", environment: \"prod\" },\n { name: \"staging\", environment: \"staging\" },\n { name: \"dev\", environment: \"dev\" },\n];\n\n/**\n * The layout both existing applications already use. Used as the default so a\n * v1 `project.json` needs no migration to keep deploying.\n */\nexport function defaultFlavour(environment: Environment): FlavourConfig {\n return {\n envFile: `build/${environment}/.env.${environment}`,\n trapezeConfig: `build/${environment}/trapeze.${environment}.yaml`,\n assetPath: `build/${environment}/assets`,\n mode: environment,\n };\n}\n\nexport function normaliseProjectConfig(config: ProjectConfig): ResolvedProjectConfig {\n const flavours = {} as Record<Environment, FlavourConfig>;\n for (const environment of ENVIRONMENTS) {\n const defaults = defaultFlavour(environment);\n const declared = config.flavours?.[environment];\n flavours[environment] = {\n envFile: declared?.envFile ?? defaults.envFile,\n trapezeConfig: declared?.trapezeConfig ?? defaults.trapezeConfig,\n assetPath: declared?.assetPath ?? defaults.assetPath,\n mode: declared?.mode ?? defaults.mode,\n };\n }\n\n // v1 expressed monorepo builds as `monorepoRoot` + `packageName`, which the\n // CLI turned into `pnpm exec vp run <pkg>#build:<env>`. Carry that forward as\n // an explicit build command so the behaviour is visible rather than implied.\n const build: BuildConfig = { ...config.build };\n if (!build.cwd && config.monorepoRoot) build.cwd = config.monorepoRoot;\n if (!build.command && config.packageName) {\n build.command = `vp run ${config.packageName}#build`;\n }\n\n return {\n version: config.version ?? 1,\n appId: config.appId,\n cloudAppId: config.cloudAppId,\n appName: config.appName,\n createdAt: config.createdAt,\n webDir: config.webDir ?? \"dist\",\n androidDir: config.androidDir ?? \"android\",\n iosDir: config.iosDir ?? \"ios\",\n versionCodeFile: config.versionCodeFile ?? \"version-code.json\",\n flavours,\n build,\n ghPagesRepo: config.ghPagesRepo,\n };\n}\n\n/** Fields a `project.json` must carry for a deploy to be possible. */\nexport function validateProjectConfig(config: Partial<ProjectConfig> | null | undefined): string[] {\n if (!config) return [\"project.json is missing or empty\"];\n\n const problems: string[] = [];\n if (!config.appId) problems.push(\"appId is required\");\n if (!config.cloudAppId) problems.push(\"cloudAppId is required\");\n if (!config.appName) problems.push(\"appName is required\");\n\n if (config.appId && !isValidBundleId(config.appId)) {\n problems.push(`appId \"${config.appId}\" is not a valid bundle identifier`);\n }\n\n return problems;\n}\n\nconst BUNDLE_ID = /^[a-z][a-z\\d_]*(\\.[a-z][a-z\\d_]*)+$/;\n\nexport function isValidBundleId(value: string): boolean {\n return BUNDLE_ID.test(value);\n}\n\n/**\n * Derives the environment a bundle identifier belongs to.\n *\n * The backend enforces the same rule server-side: a `.staging` build may only\n * be served staging channels. Mirroring it here lets the CLI refuse a\n * mismatched deploy before it uploads several megabytes.\n */\nexport function environmentFromAppId(appId: string): Environment {\n const id = appId.toLowerCase();\n if (id.endsWith(\".staging\")) return \"staging\";\n if (id.endsWith(\".dev\") || id.endsWith(\".debug\")) return \"dev\";\n return \"prod\";\n}\n\n/**\n * Whether a build may be served a channel bound to `channelEnvironment`.\n *\n * This mirrors the server's isolation check exactly, including its one\n * deliberate exception: a production build is allowed on a staging channel, so\n * a release candidate can be beta-tested by real installs without shipping a\n * separate bundle identifier.\n *\n * The rule lives here rather than being restated at each call site because the\n * CLI had reimplemented it as a plain equality check - which is *stricter* than\n * the server and rejected the exact beta-testing setup Lowmaro uses, where all\n * three channels are bound to staging and the app id carries no suffix.\n */\nexport function isEnvironmentAllowed(appId: string, channelEnvironment: Environment): boolean {\n const expected = environmentFromAppId(appId);\n if (expected === channelEnvironment) return true;\n return expected === \"prod\" && channelEnvironment === \"staging\";\n}\n\n/** Explains a rejected pairing, or null when it is allowed. */\nexport function describeEnvironmentMismatch(\n appId: string,\n channelEnvironment: Environment,\n channelName: string,\n): string | null {\n if (isEnvironmentAllowed(appId, channelEnvironment)) return null;\n\n const expected = environmentFromAppId(appId);\n return (\n `Channel \"${channelName}\" serves the ${channelEnvironment} environment, but ` +\n `the build's VITE_APP_ID is \"${appId}\", which is a ${expected} bundle id. ` +\n \"The server rejects this pairing, so the upload would be wasted.\"\n );\n}\n","import type { Environment } from \"./update-contract.js\";\n\n/**\n * Version arithmetic, kept free of any filesystem or child-process access so\n * both the CLI and the tests can use it directly.\n *\n * The CLI used to shell out to `npm version <type> --no-git-tag-version` for\n * this. In a workspace that is actively wrong: npm resolves the *nearest*\n * package.json, so running it from a monorepo root bumped the root package\n * instead of the app, and it mixed npm into a pnpm/Vite+ project for a job\n * that is three lines of string handling.\n */\n\nexport type BumpType = \"major\" | \"minor\" | \"patch\";\n\nexport interface SemanticVersion {\n major: number;\n minor: number;\n patch: number;\n prerelease?: string;\n build?: string;\n}\n\nconst SEMVER =\n /^(\\d+)\\.(\\d+)\\.(\\d+)(?:-([\\dA-Za-z-]+(?:\\.[\\dA-Za-z-]+)*))?(?:\\+([\\dA-Za-z-]+(?:\\.[\\dA-Za-z-]+)*))?$/;\n\nexport function parseVersion(value: string): SemanticVersion | null {\n const match = SEMVER.exec(value.trim());\n if (!match) return null;\n\n return {\n major: Number(match[1]),\n minor: Number(match[2]),\n patch: Number(match[3]),\n prerelease: match[4],\n build: match[5],\n };\n}\n\nexport function formatVersion(version: SemanticVersion): string {\n let out = `${version.major}.${version.minor}.${version.patch}`;\n if (version.prerelease) out += `-${version.prerelease}`;\n if (version.build) out += `+${version.build}`;\n return out;\n}\n\n/**\n * Bumps a version string. Prerelease and build metadata are dropped, matching\n * `npm version` semantics for a plain major/minor/patch bump.\n */\nexport function bumpVersion(value: string, type: BumpType): string {\n const parsed = parseVersion(value);\n if (!parsed) {\n throw new Error(`\"${value}\" is not a semantic version, so it cannot be bumped`);\n }\n\n switch (type) {\n case \"major\":\n return formatVersion({ major: parsed.major + 1, minor: 0, patch: 0 });\n case \"minor\":\n return formatVersion({\n major: parsed.major,\n minor: parsed.minor + 1,\n patch: 0,\n });\n case \"patch\":\n return formatVersion({\n major: parsed.major,\n minor: parsed.minor,\n patch: parsed.patch + 1,\n });\n }\n}\n\n/**\n * Compares two semantic versions. Returns a negative number when `a` is older.\n *\n * A missing or unparseable version sorts oldest, which is what the app needs:\n * the sentinel `\"builtin\"` must always look older than any published bundle.\n */\nexport function compareVersions(a: string, b: string): number {\n const left = parseVersion(a);\n const right = parseVersion(b);\n\n if (!left && !right) return 0;\n if (!left) return -1;\n if (!right) return 1;\n\n if (left.major !== right.major) return left.major - right.major;\n if (left.minor !== right.minor) return left.minor - right.minor;\n if (left.patch !== right.patch) return left.patch - right.patch;\n\n // 1.0.0-beta precedes 1.0.0.\n if (left.prerelease && !right.prerelease) return -1;\n if (!left.prerelease && right.prerelease) return 1;\n if (left.prerelease && right.prerelease) {\n return left.prerelease < right.prerelease ? -1 : left.prerelease > right.prerelease ? 1 : 0;\n }\n\n return 0;\n}\n\nexport type VersionCodes = Record<Environment, number>;\n\nexport const INITIAL_VERSION_CODES: VersionCodes = {\n dev: 1,\n staging: 1,\n prod: 1,\n};\n\n/**\n * Native build numbers must increase monotonically per environment: Android\n * refuses to install an APK whose versionCode is not greater than the\n * installed one, and the backend uses the same number to decide whether a\n * native update supersedes an OTA bundle.\n */\nexport function nextVersionCode(\n codes: Partial<VersionCodes> | null | undefined,\n environment: Environment,\n): VersionCodes {\n const current: VersionCodes = { ...INITIAL_VERSION_CODES, ...codes };\n return { ...current, [environment]: (current[environment] ?? 0) + 1 };\n}\n\n/**\n * Build-time variables injected into the web build and the native\n * configuration step.\n *\n * These used to be written *into* the committed `build/<env>/.env.<env>` file\n * by every deploy, which dirtied the working tree and made two concurrent\n * deploys race over one file. They are environment variables now: Trapeze\n * reads its `vars:` block from the process environment, and Vite reads\n * `VITE_*` the same way.\n */\nexport function versionEnv(version: string, versionCode: number) {\n return {\n VITE_APP_VERSION: version,\n VERSION_CODE: String(versionCode),\n BUILD_NUMBER: String(versionCode),\n };\n}\n","/**\n * What a device should install, decided from what the server found.\n *\n * `decideUpdate` is pure over facts; `renderUpdateResponse` is the only place a\n * wire response is shaped. Fetching stays in the backend.\n */\n\nimport { isEnvironmentAllowed } from \"./project-config.js\";\nimport {\n UpdateMessage,\n type Environment,\n type NativeUpdatePayload,\n type Platform,\n type UpdateCheckResponse,\n} from \"./update-contract.js\";\nimport { compareVersions } from \"./version.js\";\n\n/** The build a device is running, as it reports itself. */\nexport interface DeviceState {\n /** Bundle identifier of the binary, which carries its environment suffix. */\n appId: string;\n platform: Platform;\n /** Native build number. 0 when the device did not report one. */\n versionCode: number;\n /** Applied OTA bundle version, or `\"builtin\"` when none has landed. */\n versionName: string;\n}\n\nexport interface ChannelState {\n name: string;\n environment: Environment;\n}\n\n/** A native binary row. The column is `file_size_bytes`; the wire field is `file_size`. */\nexport interface NativeRelease {\n version_name: string;\n version_code: number;\n download_url: string;\n platform: Platform;\n required?: boolean | null;\n release_notes?: string | null;\n file_size_bytes?: number | null;\n}\n\n/** An OTA bundle row. `url` is already resolved to something downloadable. */\nexport interface OtaRelease {\n version_name: string;\n url: string;\n platform: Platform;\n checksum?: string | null;\n session_key?: string | null;\n /** Native build number this bundle needs; below it, it must not be served. */\n min_update_version?: string | number | null;\n required?: boolean | null;\n release_notes?: string | null;\n}\n\n/** Everything the server looked up. Facts only - no decisions. */\nexport interface UpdateFacts {\n device: DeviceState;\n /** null when no app carries the requested bundle identifier. */\n app: { id: string } | null;\n /** null when the app has no channel by the requested name. */\n channel: ChannelState | null;\n /** The native binary the channel points at, if any. */\n native: NativeRelease | null;\n /** The OTA bundle the channel points at, if any. */\n ota: OtaRelease | null;\n}\n\n/** The closed set of outcomes. */\nexport type UpdateDecision =\n | { kind: \"app-not-found\" }\n | { kind: \"channel-not-found\" }\n | { kind: \"environment-mismatch\"; expected: Environment; channel: ChannelState }\n | { kind: \"native\"; release: NativeRelease }\n | { kind: \"native-required\"; minVersionCode: number; installedVersionCode: number }\n | { kind: \"ota\"; release: OtaRelease }\n | { kind: \"no-bundle\" }\n | { kind: \"platform-mismatch\"; bundlePlatform: Platform; devicePlatform: Platform }\n | { kind: \"up-to-date\"; version: string };\n\n/** `min_update_version` as a number; absent, empty and unparseable all mean ungated. */\nfunction minimumNativeVersion(ota: OtaRelease): number {\n const raw = ota.min_update_version;\n if (raw === null || raw === undefined || raw === \"\") return 0;\n const parsed = typeof raw === \"number\" ? raw : Number.parseInt(raw, 10);\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;\n}\n\n/**\n * Decides what to serve. Order is load-bearing: environment gate, then native,\n * then OTA - a bundle applied to a binary too old to run it cannot be undone.\n */\nexport function decideUpdate(facts: UpdateFacts): UpdateDecision {\n const { device, app, channel, native, ota } = facts;\n\n if (!app) return { kind: \"app-not-found\" };\n if (!channel) return { kind: \"channel-not-found\" };\n\n if (!isEnvironmentAllowed(device.appId, channel.environment)) {\n return { kind: \"environment-mismatch\", expected: channel.environment, channel };\n }\n\n // Platform checked here, not at the query, so iOS is never offered an APK.\n if (native && native.platform === device.platform && native.version_code > device.versionCode) {\n return { kind: \"native\", release: native };\n }\n\n if (!ota) return { kind: \"no-bundle\" };\n\n if (ota.platform !== device.platform) {\n return {\n kind: \"platform-mismatch\",\n bundlePlatform: ota.platform,\n devicePlatform: device.platform,\n };\n }\n\n // `\"builtin\"` is unparseable, and compareVersions sorts those oldest.\n if (compareVersions(ota.version_name, device.versionName) <= 0) {\n return { kind: \"up-to-date\", version: device.versionName };\n }\n\n const minimum = minimumNativeVersion(ota);\n if (minimum > 0 && device.versionCode < minimum) {\n return {\n kind: \"native-required\",\n minVersionCode: minimum,\n installedVersionCode: device.versionCode,\n };\n }\n\n return { kind: \"ota\", release: ota };\n}\n\n/** The wire fields of a native binary, and only those - never the database row. */\nexport function nativePayload(release: NativeRelease): NativeUpdatePayload {\n return {\n version_name: release.version_name,\n version_code: release.version_code,\n download_url: release.download_url,\n platform: release.platform,\n required: release.required ?? false,\n ...(release.release_notes ? { release_notes: release.release_notes } : {}),\n ...(typeof release.file_size_bytes === \"number\" ? { file_size: release.file_size_bytes } : {}),\n };\n}\n\nexport interface RenderContext {\n /** Remote configuration for the channel's environment. */\n config: Record<string, unknown>;\n /** The binary satisfying a blocked bundle's `min_update_version`, if it exists. */\n gate?: NativeRelease | null;\n}\n\n/**\n * Turns a decision into the response the plugin reads.\n *\n * A native binary goes in `native_update`, never the top-level `url` - the\n * plugin downloads whatever is there and unzips it as a web bundle.\n */\nexport function renderUpdateResponse(\n decision: UpdateDecision,\n context: RenderContext,\n): UpdateCheckResponse {\n const { config } = context;\n\n switch (decision.kind) {\n // Neither carries config: there is no app, or no channel to resolve one for.\n // Both are misconfiguration rather than breakage, so they are \"blocked\" -\n // the plugin logs those at info and does not raise a failed update.\n case \"app-not-found\":\n return { message: UpdateMessage.APP_NOT_FOUND, kind: \"blocked\" };\n\n case \"channel-not-found\":\n return { message: UpdateMessage.CHANNEL_NOT_FOUND, kind: \"blocked\" };\n\n case \"environment-mismatch\":\n return { message: UpdateMessage.ENVIRONMENT_MISMATCH, kind: \"blocked\", config };\n\n case \"native\": {\n const payload = nativePayload(decision.release);\n return {\n message: UpdateMessage.NATIVE_UPDATE_AVAILABLE,\n // An update exists, but not one the plugin can download and unzip. Left\n // unclassified it would fall through to the bundle path, find no `url`,\n // and be reported as a failed update check.\n kind: \"blocked\",\n // Mirrored at the top level so a client that only reads the flat shape\n // still learns the version and whether it may be postponed.\n version_name: payload.version_name,\n version: payload.version_name,\n required: payload.required ?? false,\n ...(payload.release_notes ? { release_notes: payload.release_notes } : {}),\n native_update: payload,\n config,\n };\n }\n\n case \"native-required\":\n return {\n message: UpdateMessage.NATIVE_UPDATE_REQUIRED,\n // A bundle exists and the device may not have it yet - blocked, not\n // failed. Without this the plugin normalised the missing kind to\n // \"failed\" and raised downloadFailed on every check.\n kind: \"blocked\",\n error:\n `Native version ${decision.minVersionCode} required. ` +\n `You have ${decision.installedVersionCode}.`,\n ...(context.gate ? { version: context.gate.version_name } : {}),\n native_update: context.gate ? nativePayload(context.gate) : null,\n config,\n };\n\n case \"ota\": {\n const { release } = decision;\n return {\n version_name: release.version_name,\n // The name the plugin reads. Deliberately no `kind` here: the plugin\n // treats the mere presence of that key as \"this response carries no\n // bundle\" and never downloads.\n version: release.version_name,\n url: release.url,\n ...(release.checksum ? { checksum: release.checksum } : {}),\n ...(release.session_key ? { sessionKey: release.session_key } : {}),\n // Both were stored and then dropped in transit: a release marked\n // required arrived as optional, so a client offered \"Later\" on an\n // update nobody may postpone.\n required: release.required ?? false,\n ...(release.release_notes ? { release_notes: release.release_notes } : {}),\n config,\n };\n }\n\n // Nothing to serve and nothing wrong. \"up_to_date\" is the only non-error\n // classification the plugin has; our own `message` keeps the distinction.\n case \"no-bundle\":\n return { message: UpdateMessage.NO_BUNDLE, kind: \"up_to_date\", config };\n\n case \"platform-mismatch\":\n return { message: UpdateMessage.PLATFORM_MISMATCH, kind: \"up_to_date\", config };\n\n case \"up-to-date\":\n return {\n message: UpdateMessage.NO_UPDATE,\n kind: \"up_to_date\",\n version: decision.version,\n config,\n };\n }\n}\n\n/** One line naming the branch that fired, for the server log. */\nexport function describeDecision(decision: UpdateDecision): string {\n switch (decision.kind) {\n case \"app-not-found\":\n return \"no app carries this bundle identifier\";\n case \"channel-not-found\":\n return \"the app has no channel by that name\";\n case \"environment-mismatch\":\n return `a ${decision.expected} channel refused this build`;\n case \"native\":\n return `native ${decision.release.version_name} (code ${decision.release.version_code})`;\n case \"native-required\":\n return (\n `bundle gated behind native ${decision.minVersionCode}, ` +\n `device has ${decision.installedVersionCode}`\n );\n case \"ota\":\n return `bundle ${decision.release.version_name}`;\n case \"no-bundle\":\n return \"the channel points at no bundle\";\n case \"platform-mismatch\":\n return `the bundle is ${decision.bundlePlatform}, the device is ${decision.devicePlatform}`;\n case \"up-to-date\":\n return `already on ${decision.version}`;\n }\n}\n","import type { Environment, Platform } from \"./update-contract.js\";\n\n/** Shapes returned by the authenticated `/api/*` endpoints. */\n\nexport interface CloudOrganization {\n id: string;\n name: string;\n slug: string;\n role: \"owner\" | \"admin\" | \"member\";\n}\n\nexport interface CloudApp {\n id: string;\n name: string;\n app_id: string;\n platform: string;\n organization_id: string;\n created_at: string;\n icon_url?: string;\n}\n\nexport interface CloudChannel {\n id: string;\n name: string;\n app_id: string;\n /**\n * Which build flavour this channel serves. The CLI derives the whole build\n * from it, so a channel without an environment cannot be deployed to.\n */\n environment: Environment;\n public: boolean;\n created_at: string;\n current_version_id?: string | null;\n current_native_version_id?: string | null;\n}\n\nexport interface CloudRelease {\n id: string;\n version_name: string;\n platform: Platform;\n channel?: string;\n active: boolean;\n required: boolean;\n release_notes?: string;\n created_at: string;\n}\n\nexport interface CloudUser {\n id: string;\n email: string;\n role?: string;\n}\n\n/** Response of `GET /api/auth/me`. */\n/** What the credential in use is, and what it is allowed to touch. */\nexport interface CredentialScope {\n type: \"api_key\" | \"session\";\n /** Cloud id of the only app this key may publish to, or null for all of them. */\n app_id: string | null;\n}\n\nexport interface UserProfile {\n user: CloudUser;\n organizations: CloudOrganization[];\n apps: Array<CloudApp & { role: string }>;\n /** Absent from older backends, so treat undefined as \"unknown\", not \"unscoped\". */\n credential?: CredentialScope;\n}\n\n/**\n * Whether this credential can publish to an app.\n *\n * An app-scoped key can still list every app the account owns, so a scope\n * mismatch is invisible until an upload returns 403 - after a full build. Both\n * `init` and `doctor` check this up front.\n */\nexport function canPublishTo(profile: UserProfile, cloudAppId: string): boolean {\n const scope = profile.credential?.app_id;\n return !scope || scope === cloudAppId;\n}\n\n/** Roles allowed to create an application inside an organization. */\nexport const APP_CREATOR_ROLES: ReadonlySet<string> = new Set([\"owner\", \"admin\"]);\n\nexport function canCreateApps(organization: CloudOrganization): boolean {\n return APP_CREATOR_ROLES.has(organization.role);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/channel-environment.ts","../src/update-contract.ts","../src/project-config.ts","../src/version.ts","../src/update-decision.ts","../src/cloud.ts","../src/app-registration.ts","../src/role-cap.ts"],"sourcesContent":["import type { Environment } from \"./update-contract.js\";\n\n/**\n * A channel's `environment` decides which `.env` flavour the CLI builds and which\n * bundles the backend serves to it. The channel's *name* is only an identifier.\n *\n * Nothing links the two, so a channel named `prod` left on the `staging`\n * environment silently serves staging bundles to production devices - which is\n * how all three of Lowmaro's channels ended up on staging. These helpers make\n * the mismatch visible; they never correct it silently, because prod apps\n * legitimately point at a staging channel for beta testing.\n */\n\n/** Empty means \"not chosen yet\". A channel must not default into an environment. */\nexport type EnvironmentSelection = Environment | \"\";\n\nconst PATTERNS: Array<[Environment, RegExp]> = [\n [\"prod\", /^(prod|production|live|release|stable|main|master)$/],\n [\"staging\", /^(staging|stage|beta|uat|qa|test|preprod|pre-prod)$/],\n [\"dev\", /^(dev|develop|development|debug|local|alpha)$/],\n];\n\n/**\n * The environment a channel name implies, or `null` when the name says nothing.\n *\n * Matching is deliberately whole-name: a channel called `prod-eu` could belong\n * to either, and guessing at substrings would put a warning on names it cannot\n * reason about.\n */\nexport function suggestEnvironment(name: string): Environment | null {\n const normalized = name.trim().toLowerCase();\n if (!normalized) return null;\n\n for (const [environment, pattern] of PATTERNS) {\n if (pattern.test(normalized)) return environment;\n }\n\n return null;\n}\n\n/** True when the name implies one environment and a different one is selected. */\nexport function hasEnvironmentMismatch(name: string, environment: EnvironmentSelection): boolean {\n if (!environment) return false;\n\n const suggested = suggestEnvironment(name);\n return suggested !== null && suggested !== environment;\n}\n\n/** The warning to show for a mismatch, or `null` when there is nothing to warn about. */\nexport function environmentMismatchWarning(\n name: string,\n environment: EnvironmentSelection,\n): string | null {\n if (!hasEnvironmentMismatch(name, environment)) return null;\n\n const label = name.trim();\n return (\n `A channel named \"${label}\" is set to the ${environment} environment. ` +\n `Devices on it will receive ${environment} bundles, built from .env.${environment}. ` +\n `Set the environment to ${suggestEnvironment(label)} unless that is deliberate.`\n );\n}\n","/**\n * The wire contract for `POST {endpoint}/api/update`.\n *\n * This file is the single definition shared by the backend that produces the\n * response, the app runtime that consumes it, and the CLI that publishes the\n * artefacts it points at. Before this package existed the three had drifted:\n * the app template asked a second endpoint (`GET /api/native-updates/check`)\n * with an `{ available, update }` envelope the backend never returns on the\n * primary path, and sent a hard-coded `version_name: \"builtin\"` so the server\n * always compared against 0.0.0.\n */\n\nexport type Platform = \"android\" | \"ios\" | \"web\";\n\n/** Deployment environments. A channel is bound to exactly one of these. */\nexport type Environment = \"dev\" | \"staging\" | \"prod\";\n\n/**\n * Messages the backend puts in `message`. Both sides must agree on the exact\n * strings, so they live here rather than being retyped at each call site.\n */\nexport const UpdateMessage = {\n /** A newer native binary is assigned to the channel and must be installed. */\n NATIVE_UPDATE_REQUIRED: \"native_update_required\",\n /** A newer artefact is available. */\n UPDATE_AVAILABLE: \"update_available\",\n /**\n * A newer native binary is available, but not mandatory.\n *\n * Distinct from UPDATE_AVAILABLE because the top-level `url` is deliberately\n * absent: that field is the Capacitor plugin's OTA contract, and it\n * auto-downloads whatever is there and unzips it. An APK in `url` made the\n * plugin download 45 MB and fail, hiding the real update behind a download\n * error. The binary is in `native_update` instead.\n */\n NATIVE_UPDATE_AVAILABLE: \"native_update_available\",\n /** The device already runs the newest artefact for its channel. */\n NO_UPDATE: \"No update available\",\n /** No application carries the requesting bundle identifier. */\n APP_NOT_FOUND: \"App not found\",\n /** The channel name does not exist for this application. */\n CHANNEL_NOT_FOUND: \"Channel not found\",\n /**\n * The requesting app id does not belong to the channel's environment - a\n * staging build asking a production channel, for example.\n */\n ENVIRONMENT_MISMATCH: \"Environment mismatch\",\n /**\n * The channel exists but points at no bundle, and PLATFORM_MISMATCH means it\n * points at one built for another platform.\n *\n * Neither is actionable by the device, and both used to return a bare\n * `{ config: {} }` - the same response as \"you are up to date\". Three\n * different situations were indistinguishable on the wire, which is why an\n * iOS device asking an Android-only channel produced silence rather than a\n * diagnosis. Clients still take no action; the names exist so the answer to\n * \"why did nothing happen\" is in the response.\n */\n NO_BUNDLE: \"No bundle assigned\",\n PLATFORM_MISMATCH: \"Platform mismatch\",\n} as const;\n\nexport type UpdateMessageValue = (typeof UpdateMessage)[keyof typeof UpdateMessage];\n\n/**\n * How the plugin classifies a response that carries no downloadable bundle.\n *\n * Read from `@capgo/capacitor-updater@7.50.2`, which is the authority here:\n * `CapacitorUpdaterPlugin.normalizedUpdateResponseKind` (android, line 4333)\n * maps anything that is not one of these three to `\"failed\"`, and the check\n * path at line 4515 enters this branch whenever the response has *either* an\n * `error` or a `kind` key.\n *\n * Two consequences the backend must respect, both of which it violated:\n *\n * 1. A response that carries an update must NOT set `kind`, or the plugin\n * classifies it instead of downloading it.\n * 2. A response that carries no update MUST set `kind`, or it is reported as a\n * failed update check - which is where the app's \"the update could not be\n * downloaded\" came from on a device that was simply up to date.\n */\nexport type UpdateResponseKind = \"up_to_date\" | \"blocked\" | \"failed\";\n\n/** What the device tells the server about itself. */\nexport interface UpdateCheckRequest {\n /** Bundle identifier of the running build, e.g. `com.ayb.lowmaro.staging`. */\n appId: string;\n platform: Platform;\n /** Channel to consult. Falls back to `defaultChannel` server-side. */\n channel?: string;\n defaultChannel?: string;\n /**\n * Native build number as a string. The server compares this against\n * `native_updates.version_code` and against an OTA bundle's\n * `min_update_version`, so an omitted or wrong value silently disables\n * native-update gating.\n */\n versionCode?: string;\n /** Historical alias for `versionCode`; the server accepts either. */\n versionBuild?: string;\n /**\n * Semantic version of the *currently applied web bundle*, or `\"builtin\"`\n * when the app still runs the bundle shipped inside the binary. Sending a\n * constant here defeats version comparison entirely.\n */\n version_name?: string;\n /** Stable per-install identifier, used for channel overrides and stats. */\n deviceId?: string;\n isProd?: boolean;\n /**\n * Device facts the server stores but does not decide with. All optional: an\n * app that cannot determine one should omit it rather than send a placeholder,\n * because the server writes only the keys it receives and a placeholder would\n * overwrite a better value recorded earlier.\n */\n versionOs?: string;\n pluginVersion?: string;\n /**\n * Bundle version compiled into the binary. `version_name` is the *applied*\n * OTA bundle and is absent until one lands, so the two together are what say\n * whether a device has ever taken an update.\n */\n versionBuiltin?: string;\n isEmulator?: boolean;\n /** Caller-supplied label for this install, shown in the dashboard. */\n customId?: string;\n}\n\n/** A native binary (APK/IPA) the device should install. */\nexport interface NativeUpdatePayload {\n version_name: string;\n version_code: number;\n download_url: string;\n release_notes?: string;\n required?: boolean;\n platform?: Platform;\n /**\n * Size in bytes, so a client can warn before spending someone's mobile data\n * on 45 MB. The column is `file_size_bytes`; the two were never mapped, so\n * this was declared here and never once populated until `nativePayload`\n * translated it.\n */\n file_size?: number;\n}\n\n/**\n * The response. Every field is optional because the server returns a partial\n * object per outcome rather than a discriminated union - `resolveUpdate` below\n * narrows it into something a caller can branch on safely.\n */\nexport interface UpdateCheckResponse {\n message?: string;\n error?: string;\n\n /**\n * Classification for a response that carries no bundle. Absent - and it must\n * be absent - when one is offered. See `UpdateResponseKind`.\n */\n kind?: UpdateResponseKind;\n\n /** OTA bundle fields. */\n version_name?: string;\n /**\n * The same value as `version_name`, under the name the Capacitor plugin\n * reads.\n *\n * `CapacitorUpdaterPlugin` line 4551 calls `jsRes.getString(\"version\")`\n * unconditionally once a response is not classified, and a missing key throws\n * a JSONException that is caught as \"error in update check\". The backend sent\n * only `version_name`, so every background check the plugin made - on every\n * response, including a perfectly good bundle - ended as a failed update. Our\n * own runtime never noticed because it reads the response itself.\n */\n version?: string;\n url?: string;\n checksum?: string;\n sessionKey?: string;\n release_notes?: string;\n required?: boolean;\n\n /** Present when a native binary supersedes, or blocks, the OTA bundle. */\n native_update?: NativeUpdatePayload | null;\n\n /** Remote configuration resolved for the channel's environment. */\n config?: Record<string, string>;\n}\n\nexport type UpdateKind = \"native\" | \"ota\";\n\n/** A resolved, actionable update. */\nexport interface ResolvedUpdate {\n kind: UpdateKind;\n version: string;\n versionCode?: number;\n downloadUrl?: string;\n releaseNotes?: string;\n required: boolean;\n platform?: Platform;\n checksum?: string;\n sessionKey?: string;\n /**\n * Size in bytes of a native binary, when the server published one.\n *\n * The runtime verifies a cached download against it before reusing the file:\n * a connection dropped mid-download leaves a partial APK at the right path,\n * and installing that fails with \"There was a problem parsing the package\".\n */\n fileSize?: number;\n /** Set once the OTA plugin has downloaded the bundle. */\n bundleId?: string;\n}\n\n/**\n * Narrows a raw response into an update to act on, or `null` when there is\n * nothing to do.\n *\n * Native wins over OTA. The server can return both - a required native binary\n * alongside the OTA bundle that needs it - and installing the bundle first\n * would leave the device on a binary too old to run it.\n */\nexport function resolveUpdate(\n response: UpdateCheckResponse | null | undefined,\n): ResolvedUpdate | null {\n if (!response) return null;\n\n const native = response.native_update;\n if (native?.download_url) {\n return {\n kind: \"native\",\n version: native.version_name,\n versionCode: native.version_code,\n downloadUrl: native.download_url,\n releaseNotes: native.release_notes,\n // A native update is mandatory when the server says the OTA bundle\n // cannot run without it, whatever the record's own flag says.\n required:\n response.message === UpdateMessage.NATIVE_UPDATE_REQUIRED || (native.required ?? false),\n platform: native.platform,\n ...(typeof native.file_size === \"number\" ? { fileSize: native.file_size } : {}),\n };\n }\n\n if (response.url && response.version_name) {\n return {\n kind: \"ota\",\n version: response.version_name,\n downloadUrl: response.url,\n releaseNotes: response.release_notes,\n required: response.required ?? false,\n checksum: response.checksum,\n sessionKey: response.sessionKey,\n };\n }\n\n return null;\n}\n\n/**\n * True when the response reports a condition the user cannot fix by updating.\n * Callers should surface these instead of showing \"you are up to date\".\n */\nexport function isBlockingResponse(response: UpdateCheckResponse): boolean {\n return (\n response.message === UpdateMessage.CHANNEL_NOT_FOUND ||\n response.message === UpdateMessage.ENVIRONMENT_MISMATCH\n );\n}\n\n/**\n * Analytics events posted to `POST {endpoint}/api/native-updates/log`.\n *\n * A list rather than a bare union because `native_update_logs.event` carries a\n * CHECK constraint, and the two had drifted: the column allowed `check`,\n * `download`, `install`, `fail` and `skip` while this declared `check`,\n * `download`, `download_complete`, `install`, `cancel` and `error`. Three of\n * the six were rejected by the database, so a device reporting\n * `download_complete` - which is what one does after every native download -\n * got a 500. `native-update-events.test.ts` reads the migration and fails if\n * this list ever moves ahead of it again.\n */\nexport const UPDATE_EVENTS = [\n \"check\",\n \"download\",\n \"download_complete\",\n \"install\",\n \"cancel\",\n \"error\",\n] as const;\n\nexport type UpdateEvent = (typeof UPDATE_EVENTS)[number];\n\nexport interface UpdateEventPayload {\n event: UpdateEvent;\n platform: Platform;\n /**\n * Bundle identifier of the running build.\n *\n * `native_update_logs.app_id` is NOT NULL and the server cannot resolve a row\n * without it, so it rejects a payload that omits this with a 400. This field\n * was missing from the contract and from the app runtime, so **every** native\n * download, install and error event was rejected - and because the runtime\n * catches the failure and warns, nothing ever surfaced. It was found by\n * reading the WebView console on a device mid-install.\n */\n app_id: string;\n device_id: string;\n current_version_code: number;\n new_version?: string;\n new_version_code?: number;\n channel: string;\n environment: string;\n /** Failure detail for an `error` event. Older servers read `error_message`. */\n error?: string;\n}\n\n/** Field names a payload must carry for the server to record it. */\nexport const UPDATE_EVENT_REQUIRED = [\"event\", \"platform\", \"app_id\"] as const;\n\n/**\n * Validates an incoming update event, naming everything that is missing.\n *\n * Pure, and shared with the server, so \"what the client sends\" and \"what the\n * server accepts\" cannot drift the way they did here: the client sent `error`\n * and the server read `error_message`, so even a payload that got past\n * validation lost its failure detail.\n */\nexport function parseUpdateEvent(\n body: Record<string, unknown>,\n): { ok: true; event: UpdateEventPayload } | { ok: false; missing: string[] } {\n // Accepted under either name, so an app built against an older contract still\n // records rather than having its events silently dropped.\n const appId = (body.app_id ?? body.appId) as string | undefined;\n\n const missing: string[] = UPDATE_EVENT_REQUIRED.filter((field) =>\n field === \"app_id\" ? !appId : !body[field],\n );\n\n if (missing.length > 0) return { ok: false, missing };\n\n return {\n ok: true,\n event: {\n event: body.event as UpdateEvent,\n platform: body.platform as Platform,\n app_id: appId as string,\n device_id: (body.device_id ?? \"\") as string,\n current_version_code: Number(body.current_version_code ?? 0),\n new_version: body.new_version as string | undefined,\n new_version_code:\n body.new_version_code === undefined ? undefined : Number(body.new_version_code),\n channel: (body.channel ?? \"\") as string,\n environment: (body.environment ?? \"\") as string,\n error: (body.error ?? body.error_message) as string | undefined,\n },\n };\n}\n","import type { Environment } from \"./update-contract.js\";\n\n/**\n * `.capuchoo/project.json` - the file that makes an application deployable.\n *\n * Version 1 held only the cloud identifiers, so the CLI had to *guess* how to\n * build: it shelled out to `pnpm run assets:<env>`, `pnpm build:<env>`,\n * `pnpm trapeze:<env>` and `pnpm exec cap sync`. Any application that named\n * its scripts differently, used npm, or had not installed Trapeze simply\n * failed halfway through a deploy.\n *\n * Version 2 describes the *inputs* instead - where each flavour's env file,\n * Trapeze config and icon sources live - and lets the CLI own the execution.\n * Every field has a default, so a v1 file keeps working: `normaliseProjectConfig`\n * fills in the conventional layout both existing apps already use.\n */\nexport const PROJECT_CONFIG_VERSION = 2;\n\n/** One build flavour, keyed by the environment its channels are bound to. */\nexport interface FlavourConfig {\n /**\n * Env file supplying `VITE_APP_ID`, `VITE_APP_NAME`, `VITE_UPDATE_CHANNEL`\n * and friends. The CLI reads it and passes the values to the build and to\n * the native configuration step as environment variables - it does not\n * rewrite the file, so a deploy leaves the working tree clean.\n */\n envFile: string;\n /** Trapeze config applied to the native projects. Optional. */\n trapezeConfig?: string;\n /** Directory holding `icon.png` / `splash.png` for icon generation. */\n assetPath?: string;\n /** Vite `--mode`. Defaults to the flavour name. */\n mode?: string;\n}\n\nexport interface BuildConfig {\n /**\n * Overrides the web build. Leave unset and the CLI runs the project's own\n * Vite build through the workspace toolchain it detects.\n */\n command?: string;\n /** Where to run the build from, relative to the app. For monorepo roots. */\n cwd?: string;\n}\n\nexport interface ProjectConfig {\n /** Absent on v1 files. */\n version?: number;\n\n /** Bundle identifier of the production flavour. */\n appId: string;\n /** Primary key of the application in Capuchoo. */\n cloudAppId: string;\n appName: string;\n createdAt: string;\n\n /** Vite output directory, and what Capacitor copies into the native app. */\n webDir?: string;\n androidDir?: string;\n iosDir?: string;\n\n /**\n * Monotonic native build numbers per environment. Written by the CLI, and\n * the one file a deploy is expected to modify.\n */\n versionCodeFile?: string;\n\n flavours?: Partial<Record<Environment, FlavourConfig>>;\n build?: BuildConfig;\n\n /** Optional GitHub Pages mirror for generated web assets. */\n ghPagesRepo?: string;\n\n /** @deprecated v1 fields, folded into `build` by `normaliseProjectConfig`. */\n monorepoRoot?: string;\n /** @deprecated v1 field. */\n packageName?: string;\n}\n\n/** A `ProjectConfig` with every optional resolved. */\nexport interface ResolvedProjectConfig {\n version: number;\n appId: string;\n cloudAppId: string;\n appName: string;\n createdAt: string;\n webDir: string;\n androidDir: string;\n iosDir: string;\n versionCodeFile: string;\n flavours: Record<Environment, FlavourConfig>;\n build: BuildConfig;\n ghPagesRepo?: string;\n}\n\nexport const ENVIRONMENTS: readonly Environment[] = [\"dev\", \"staging\", \"prod\"];\n\n/**\n * The channels every app gets when it is created.\n *\n * A channel and an environment are different axes, and conflating them is\n * confusing enough to be worth writing down here:\n *\n * - **environment** is a safety gate. It decides which *builds* may be served\n * a channel - a `.staging` bundle id cannot be handed a prod channel - so it\n * exists to stop a staging bundle reaching production devices.\n * - **channel** is an audience. It decides which release a group of devices\n * receives.\n *\n * These three are the *pipeline*, one per environment, and they are what an app\n * needs before anything can be deployed to it at all. A new app had none, so the\n * first deploy failed on a channel/environment pairing the user had to reason out\n * from an error message.\n *\n * Anything beyond these is an *audience*, not a stage: a per-client channel or\n * an A/B arm is an extra channel on the **prod** environment, because the people\n * on it are running production builds. It is not another set of three.\n */\nexport const DEFAULT_CHANNELS: ReadonlyArray<{ name: string; environment: Environment }> = [\n { name: \"prod\", environment: \"prod\" },\n { name: \"staging\", environment: \"staging\" },\n { name: \"dev\", environment: \"dev\" },\n];\n\n/**\n * The layout both existing applications already use. Used as the default so a\n * v1 `project.json` needs no migration to keep deploying.\n */\nexport function defaultFlavour(environment: Environment): FlavourConfig {\n return {\n envFile: `build/${environment}/.env.${environment}`,\n trapezeConfig: `build/${environment}/trapeze.${environment}.yaml`,\n assetPath: `build/${environment}/assets`,\n mode: environment,\n };\n}\n\nexport function normaliseProjectConfig(config: ProjectConfig): ResolvedProjectConfig {\n const flavours = {} as Record<Environment, FlavourConfig>;\n for (const environment of ENVIRONMENTS) {\n const defaults = defaultFlavour(environment);\n const declared = config.flavours?.[environment];\n flavours[environment] = {\n envFile: declared?.envFile ?? defaults.envFile,\n trapezeConfig: declared?.trapezeConfig ?? defaults.trapezeConfig,\n assetPath: declared?.assetPath ?? defaults.assetPath,\n mode: declared?.mode ?? defaults.mode,\n };\n }\n\n // v1 expressed monorepo builds as `monorepoRoot` + `packageName`, which the\n // CLI turned into `pnpm exec vp run <pkg>#build:<env>`. Carry that forward as\n // an explicit build command so the behaviour is visible rather than implied.\n const build: BuildConfig = { ...config.build };\n if (!build.cwd && config.monorepoRoot) build.cwd = config.monorepoRoot;\n if (!build.command && config.packageName) {\n build.command = `vp run ${config.packageName}#build`;\n }\n\n return {\n version: config.version ?? 1,\n appId: config.appId,\n cloudAppId: config.cloudAppId,\n appName: config.appName,\n createdAt: config.createdAt,\n webDir: config.webDir ?? \"dist\",\n androidDir: config.androidDir ?? \"android\",\n iosDir: config.iosDir ?? \"ios\",\n versionCodeFile: config.versionCodeFile ?? \"version-code.json\",\n flavours,\n build,\n ghPagesRepo: config.ghPagesRepo,\n };\n}\n\n/** Fields a `project.json` must carry for a deploy to be possible. */\nexport function validateProjectConfig(config: Partial<ProjectConfig> | null | undefined): string[] {\n if (!config) return [\"project.json is missing or empty\"];\n\n const problems: string[] = [];\n if (!config.appId) problems.push(\"appId is required\");\n if (!config.cloudAppId) problems.push(\"cloudAppId is required\");\n if (!config.appName) problems.push(\"appName is required\");\n\n if (config.appId && !isValidBundleId(config.appId)) {\n problems.push(`appId \"${config.appId}\" is not a valid bundle identifier`);\n }\n\n return problems;\n}\n\nconst BUNDLE_ID = /^[a-z][a-z\\d_]*(\\.[a-z][a-z\\d_]*)+$/;\n\nexport function isValidBundleId(value: string): boolean {\n return BUNDLE_ID.test(value);\n}\n\n/**\n * Derives the environment a bundle identifier belongs to.\n *\n * The backend enforces the same rule server-side: a `.staging` build may only\n * be served staging channels. Mirroring it here lets the CLI refuse a\n * mismatched deploy before it uploads several megabytes.\n */\nexport function environmentFromAppId(appId: string): Environment {\n const id = appId.toLowerCase();\n if (id.endsWith(\".staging\")) return \"staging\";\n if (id.endsWith(\".dev\") || id.endsWith(\".debug\")) return \"dev\";\n return \"prod\";\n}\n\n/**\n * Whether a build may be served a channel bound to `channelEnvironment`.\n *\n * This mirrors the server's isolation check exactly, including its one\n * deliberate exception: a production build is allowed on a staging channel, so\n * a release candidate can be beta-tested by real installs without shipping a\n * separate bundle identifier.\n *\n * The rule lives here rather than being restated at each call site because the\n * CLI had reimplemented it as a plain equality check - which is *stricter* than\n * the server and rejected the exact beta-testing setup Lowmaro uses, where all\n * three channels are bound to staging and the app id carries no suffix.\n */\nexport function isEnvironmentAllowed(appId: string, channelEnvironment: Environment): boolean {\n const expected = environmentFromAppId(appId);\n if (expected === channelEnvironment) return true;\n return expected === \"prod\" && channelEnvironment === \"staging\";\n}\n\n/** Explains a rejected pairing, or null when it is allowed. */\nexport function describeEnvironmentMismatch(\n appId: string,\n channelEnvironment: Environment,\n channelName: string,\n): string | null {\n if (isEnvironmentAllowed(appId, channelEnvironment)) return null;\n\n const expected = environmentFromAppId(appId);\n return (\n `Channel \"${channelName}\" serves the ${channelEnvironment} environment, but ` +\n `the build's VITE_APP_ID is \"${appId}\", which is a ${expected} bundle id. ` +\n \"The server rejects this pairing, so the upload would be wasted.\"\n );\n}\n","import type { Environment } from \"./update-contract.js\";\n\n/**\n * Version arithmetic, kept free of any filesystem or child-process access so\n * both the CLI and the tests can use it directly.\n *\n * The CLI used to shell out to `npm version <type> --no-git-tag-version` for\n * this. In a workspace that is actively wrong: npm resolves the *nearest*\n * package.json, so running it from a monorepo root bumped the root package\n * instead of the app, and it mixed npm into a pnpm/Vite+ project for a job\n * that is three lines of string handling.\n */\n\nexport type BumpType = \"major\" | \"minor\" | \"patch\";\n\nexport interface SemanticVersion {\n major: number;\n minor: number;\n patch: number;\n prerelease?: string;\n build?: string;\n}\n\nconst SEMVER =\n /^(\\d+)\\.(\\d+)\\.(\\d+)(?:-([\\dA-Za-z-]+(?:\\.[\\dA-Za-z-]+)*))?(?:\\+([\\dA-Za-z-]+(?:\\.[\\dA-Za-z-]+)*))?$/;\n\nexport function parseVersion(value: string): SemanticVersion | null {\n const match = SEMVER.exec(value.trim());\n if (!match) return null;\n\n return {\n major: Number(match[1]),\n minor: Number(match[2]),\n patch: Number(match[3]),\n prerelease: match[4],\n build: match[5],\n };\n}\n\nexport function formatVersion(version: SemanticVersion): string {\n let out = `${version.major}.${version.minor}.${version.patch}`;\n if (version.prerelease) out += `-${version.prerelease}`;\n if (version.build) out += `+${version.build}`;\n return out;\n}\n\n/**\n * Bumps a version string. Prerelease and build metadata are dropped, matching\n * `npm version` semantics for a plain major/minor/patch bump.\n */\nexport function bumpVersion(value: string, type: BumpType): string {\n const parsed = parseVersion(value);\n if (!parsed) {\n throw new Error(`\"${value}\" is not a semantic version, so it cannot be bumped`);\n }\n\n switch (type) {\n case \"major\":\n return formatVersion({ major: parsed.major + 1, minor: 0, patch: 0 });\n case \"minor\":\n return formatVersion({\n major: parsed.major,\n minor: parsed.minor + 1,\n patch: 0,\n });\n case \"patch\":\n return formatVersion({\n major: parsed.major,\n minor: parsed.minor,\n patch: parsed.patch + 1,\n });\n }\n}\n\n/**\n * Compares two semantic versions. Returns a negative number when `a` is older.\n *\n * A missing or unparseable version sorts oldest, which is what the app needs:\n * the sentinel `\"builtin\"` must always look older than any published bundle.\n */\nexport function compareVersions(a: string, b: string): number {\n const left = parseVersion(a);\n const right = parseVersion(b);\n\n if (!left && !right) return 0;\n if (!left) return -1;\n if (!right) return 1;\n\n if (left.major !== right.major) return left.major - right.major;\n if (left.minor !== right.minor) return left.minor - right.minor;\n if (left.patch !== right.patch) return left.patch - right.patch;\n\n // 1.0.0-beta precedes 1.0.0.\n if (left.prerelease && !right.prerelease) return -1;\n if (!left.prerelease && right.prerelease) return 1;\n if (left.prerelease && right.prerelease) {\n return left.prerelease < right.prerelease ? -1 : left.prerelease > right.prerelease ? 1 : 0;\n }\n\n return 0;\n}\n\nexport type VersionCodes = Record<Environment, number>;\n\nexport const INITIAL_VERSION_CODES: VersionCodes = {\n dev: 1,\n staging: 1,\n prod: 1,\n};\n\n/**\n * Native build numbers must increase monotonically per environment: Android\n * refuses to install an APK whose versionCode is not greater than the\n * installed one, and the backend uses the same number to decide whether a\n * native update supersedes an OTA bundle.\n */\nexport function nextVersionCode(\n codes: Partial<VersionCodes> | null | undefined,\n environment: Environment,\n): VersionCodes {\n const current: VersionCodes = { ...INITIAL_VERSION_CODES, ...codes };\n return { ...current, [environment]: (current[environment] ?? 0) + 1 };\n}\n\n/**\n * Build-time variables injected into the web build and the native\n * configuration step.\n *\n * These used to be written *into* the committed `build/<env>/.env.<env>` file\n * by every deploy, which dirtied the working tree and made two concurrent\n * deploys race over one file. They are environment variables now: Trapeze\n * reads its `vars:` block from the process environment, and Vite reads\n * `VITE_*` the same way.\n */\nexport function versionEnv(version: string, versionCode: number) {\n return {\n VITE_APP_VERSION: version,\n VERSION_CODE: String(versionCode),\n BUILD_NUMBER: String(versionCode),\n };\n}\n","/**\n * What a device should install, decided from what the server found.\n *\n * `decideUpdate` is pure over facts; `renderUpdateResponse` is the only place a\n * wire response is shaped. Fetching stays in the backend.\n */\n\nimport { isEnvironmentAllowed } from \"./project-config.js\";\nimport {\n UpdateMessage,\n type Environment,\n type NativeUpdatePayload,\n type Platform,\n type UpdateCheckResponse,\n} from \"./update-contract.js\";\nimport { compareVersions } from \"./version.js\";\n\n/** The build a device is running, as it reports itself. */\nexport interface DeviceState {\n /** Bundle identifier of the binary, which carries its environment suffix. */\n appId: string;\n platform: Platform;\n /** Native build number. 0 when the device did not report one. */\n versionCode: number;\n /** Applied OTA bundle version, or `\"builtin\"` when none has landed. */\n versionName: string;\n}\n\nexport interface ChannelState {\n name: string;\n environment: Environment;\n}\n\n/** A native binary row. The column is `file_size_bytes`; the wire field is `file_size`. */\nexport interface NativeRelease {\n version_name: string;\n version_code: number;\n download_url: string;\n platform: Platform;\n required?: boolean | null;\n release_notes?: string | null;\n file_size_bytes?: number | null;\n}\n\n/** An OTA bundle row. `url` is already resolved to something downloadable. */\nexport interface OtaRelease {\n version_name: string;\n url: string;\n platform: Platform;\n checksum?: string | null;\n session_key?: string | null;\n /** Native build number this bundle needs; below it, it must not be served. */\n min_update_version?: string | number | null;\n required?: boolean | null;\n release_notes?: string | null;\n}\n\n/** Everything the server looked up. Facts only - no decisions. */\nexport interface UpdateFacts {\n device: DeviceState;\n /** null when no app carries the requested bundle identifier. */\n app: { id: string } | null;\n /** null when the app has no channel by the requested name. */\n channel: ChannelState | null;\n /** The native binary the channel points at, if any. */\n native: NativeRelease | null;\n /** The OTA bundle the channel points at, if any. */\n ota: OtaRelease | null;\n}\n\n/** The closed set of outcomes. */\nexport type UpdateDecision =\n | { kind: \"app-not-found\" }\n | { kind: \"channel-not-found\" }\n | { kind: \"environment-mismatch\"; expected: Environment; channel: ChannelState }\n | { kind: \"native\"; release: NativeRelease }\n | { kind: \"native-required\"; minVersionCode: number; installedVersionCode: number }\n | { kind: \"ota\"; release: OtaRelease }\n | { kind: \"no-bundle\" }\n | { kind: \"platform-mismatch\"; bundlePlatform: Platform; devicePlatform: Platform }\n | { kind: \"up-to-date\"; version: string };\n\n/** `min_update_version` as a number; absent, empty and unparseable all mean ungated. */\nfunction minimumNativeVersion(ota: OtaRelease): number {\n const raw = ota.min_update_version;\n if (raw === null || raw === undefined || raw === \"\") return 0;\n const parsed = typeof raw === \"number\" ? raw : Number.parseInt(raw, 10);\n return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;\n}\n\n/**\n * Decides what to serve. Order is load-bearing: environment gate, then native,\n * then OTA - a bundle applied to a binary too old to run it cannot be undone.\n */\nexport function decideUpdate(facts: UpdateFacts): UpdateDecision {\n const { device, app, channel, native, ota } = facts;\n\n if (!app) return { kind: \"app-not-found\" };\n if (!channel) return { kind: \"channel-not-found\" };\n\n if (!isEnvironmentAllowed(device.appId, channel.environment)) {\n return { kind: \"environment-mismatch\", expected: channel.environment, channel };\n }\n\n // Platform checked here, not at the query, so iOS is never offered an APK.\n if (native && native.platform === device.platform && native.version_code > device.versionCode) {\n return { kind: \"native\", release: native };\n }\n\n if (!ota) return { kind: \"no-bundle\" };\n\n if (ota.platform !== device.platform) {\n return {\n kind: \"platform-mismatch\",\n bundlePlatform: ota.platform,\n devicePlatform: device.platform,\n };\n }\n\n // `\"builtin\"` is unparseable, and compareVersions sorts those oldest.\n if (compareVersions(ota.version_name, device.versionName) <= 0) {\n return { kind: \"up-to-date\", version: device.versionName };\n }\n\n const minimum = minimumNativeVersion(ota);\n if (minimum > 0 && device.versionCode < minimum) {\n return {\n kind: \"native-required\",\n minVersionCode: minimum,\n installedVersionCode: device.versionCode,\n };\n }\n\n return { kind: \"ota\", release: ota };\n}\n\n/** The wire fields of a native binary, and only those - never the database row. */\nexport function nativePayload(release: NativeRelease): NativeUpdatePayload {\n return {\n version_name: release.version_name,\n version_code: release.version_code,\n download_url: release.download_url,\n platform: release.platform,\n required: release.required ?? false,\n ...(release.release_notes ? { release_notes: release.release_notes } : {}),\n ...(typeof release.file_size_bytes === \"number\" ? { file_size: release.file_size_bytes } : {}),\n };\n}\n\nexport interface RenderContext {\n /** Remote configuration for the channel's environment. */\n config: Record<string, unknown>;\n /** The binary satisfying a blocked bundle's `min_update_version`, if it exists. */\n gate?: NativeRelease | null;\n}\n\n/**\n * Turns a decision into the response the plugin reads.\n *\n * A native binary goes in `native_update`, never the top-level `url` - the\n * plugin downloads whatever is there and unzips it as a web bundle.\n */\nexport function renderUpdateResponse(\n decision: UpdateDecision,\n context: RenderContext,\n): UpdateCheckResponse {\n const { config } = context;\n\n switch (decision.kind) {\n // Neither carries config: there is no app, or no channel to resolve one for.\n // Both are misconfiguration rather than breakage, so they are \"blocked\" -\n // the plugin logs those at info and does not raise a failed update.\n case \"app-not-found\":\n return { message: UpdateMessage.APP_NOT_FOUND, kind: \"blocked\" };\n\n case \"channel-not-found\":\n return { message: UpdateMessage.CHANNEL_NOT_FOUND, kind: \"blocked\" };\n\n case \"environment-mismatch\":\n return { message: UpdateMessage.ENVIRONMENT_MISMATCH, kind: \"blocked\", config };\n\n case \"native\": {\n const payload = nativePayload(decision.release);\n return {\n message: UpdateMessage.NATIVE_UPDATE_AVAILABLE,\n // An update exists, but not one the plugin can download and unzip. Left\n // unclassified it would fall through to the bundle path, find no `url`,\n // and be reported as a failed update check.\n kind: \"blocked\",\n // Mirrored at the top level so a client that only reads the flat shape\n // still learns the version and whether it may be postponed.\n version_name: payload.version_name,\n version: payload.version_name,\n required: payload.required ?? false,\n ...(payload.release_notes ? { release_notes: payload.release_notes } : {}),\n native_update: payload,\n config,\n };\n }\n\n case \"native-required\":\n return {\n message: UpdateMessage.NATIVE_UPDATE_REQUIRED,\n // A bundle exists and the device may not have it yet - blocked, not\n // failed. Without this the plugin normalised the missing kind to\n // \"failed\" and raised downloadFailed on every check.\n kind: \"blocked\",\n error:\n `Native version ${decision.minVersionCode} required. ` +\n `You have ${decision.installedVersionCode}.`,\n ...(context.gate ? { version: context.gate.version_name } : {}),\n native_update: context.gate ? nativePayload(context.gate) : null,\n config,\n };\n\n case \"ota\": {\n const { release } = decision;\n return {\n version_name: release.version_name,\n // The name the plugin reads. Deliberately no `kind` here: the plugin\n // treats the mere presence of that key as \"this response carries no\n // bundle\" and never downloads.\n version: release.version_name,\n url: release.url,\n ...(release.checksum ? { checksum: release.checksum } : {}),\n ...(release.session_key ? { sessionKey: release.session_key } : {}),\n // Both were stored and then dropped in transit: a release marked\n // required arrived as optional, so a client offered \"Later\" on an\n // update nobody may postpone.\n required: release.required ?? false,\n ...(release.release_notes ? { release_notes: release.release_notes } : {}),\n config,\n };\n }\n\n // Nothing to serve and nothing wrong. \"up_to_date\" is the only non-error\n // classification the plugin has; our own `message` keeps the distinction.\n case \"no-bundle\":\n return { message: UpdateMessage.NO_BUNDLE, kind: \"up_to_date\", config };\n\n case \"platform-mismatch\":\n return { message: UpdateMessage.PLATFORM_MISMATCH, kind: \"up_to_date\", config };\n\n case \"up-to-date\":\n return {\n message: UpdateMessage.NO_UPDATE,\n kind: \"up_to_date\",\n version: decision.version,\n config,\n };\n }\n}\n\n/** One line naming the branch that fired, for the server log. */\nexport function describeDecision(decision: UpdateDecision): string {\n switch (decision.kind) {\n case \"app-not-found\":\n return \"no app carries this bundle identifier\";\n case \"channel-not-found\":\n return \"the app has no channel by that name\";\n case \"environment-mismatch\":\n return `a ${decision.expected} channel refused this build`;\n case \"native\":\n return `native ${decision.release.version_name} (code ${decision.release.version_code})`;\n case \"native-required\":\n return (\n `bundle gated behind native ${decision.minVersionCode}, ` +\n `device has ${decision.installedVersionCode}`\n );\n case \"ota\":\n return `bundle ${decision.release.version_name}`;\n case \"no-bundle\":\n return \"the channel points at no bundle\";\n case \"platform-mismatch\":\n return `the bundle is ${decision.bundlePlatform}, the device is ${decision.devicePlatform}`;\n case \"up-to-date\":\n return `already on ${decision.version}`;\n }\n}\n","import type { Environment, Platform } from \"./update-contract.js\";\n\n/** Shapes returned by the authenticated `/api/*` endpoints. */\n\nexport interface CloudOrganization {\n id: string;\n name: string;\n slug: string;\n role: \"owner\" | \"admin\" | \"member\";\n}\n\nexport interface CloudApp {\n id: string;\n name: string;\n app_id: string;\n platform: string;\n organization_id: string;\n created_at: string;\n icon_url?: string;\n}\n\nexport interface CloudChannel {\n id: string;\n name: string;\n app_id: string;\n /**\n * Which build flavour this channel serves. The CLI derives the whole build\n * from it, so a channel without an environment cannot be deployed to.\n */\n environment: Environment;\n public: boolean;\n created_at: string;\n current_version_id?: string | null;\n current_native_version_id?: string | null;\n}\n\nexport interface CloudRelease {\n id: string;\n version_name: string;\n platform: Platform;\n channel?: string;\n active: boolean;\n required: boolean;\n release_notes?: string;\n created_at: string;\n}\n\nexport interface CloudUser {\n id: string;\n email: string;\n role?: string;\n}\n\n/** Response of `GET /api/auth/me`. */\n/** What the credential in use is, and what it is allowed to touch. */\nexport interface CredentialScope {\n type: \"api_key\" | \"session\";\n /** Cloud id of the only app this key may publish to, or null for all of them. */\n app_id: string | null;\n}\n\nexport interface UserProfile {\n user: CloudUser;\n organizations: CloudOrganization[];\n apps: Array<CloudApp & { role: string }>;\n /** Absent from older backends, so treat undefined as \"unknown\", not \"unscoped\". */\n credential?: CredentialScope;\n}\n\n/**\n * Whether this credential can publish to an app.\n *\n * An app-scoped key can still list every app the account owns, so a scope\n * mismatch is invisible until an upload returns 403 - after a full build. Both\n * `init` and `doctor` check this up front.\n */\nexport function canPublishTo(profile: UserProfile, cloudAppId: string): boolean {\n const scope = profile.credential?.app_id;\n return !scope || scope === cloudAppId;\n}\n\n/** Roles allowed to create an application inside an organization. */\nexport const APP_CREATOR_ROLES: ReadonlySet<string> = new Set([\"owner\", \"admin\"]);\n\nexport function canCreateApps(organization: CloudOrganization): boolean {\n return APP_CREATOR_ROLES.has(organization.role);\n}\n","/**\n * What happens when a bundle identifier is registered that already exists.\n *\n * `apps.app_id` is unique across the whole installation, so the second attempt\n * hits a constraint rather than a permission check - and the caller cannot see\n * the row that blocks them, because the listing is scoped to what they may\n * access. Left as a raw 23505 that becomes a 500, the state is unreachable:\n * the app cannot be created and cannot be seen.\n */\n\nexport interface ExistingApp {\n id: string;\n app_id: string;\n organization_id: string | null;\n}\n\nexport interface AppRegistrationFacts {\n appId: string;\n /** The organisation the caller asked to register in. */\n requestedOrganizationId: string;\n /** The row already holding this bundle id, if any. */\n existing?: ExistingApp | null;\n /** Whether `existing.organization_id` still resolves to a real organisation. */\n existingOrganizationExists?: boolean;\n /** Whether the caller already holds a direct app_permissions grant on it. */\n callerHasDirectPermission?: boolean;\n}\n\nexport type AdoptionReason = \"same-organisation\" | \"direct-permission\" | \"orphaned\";\n\nexport type AppRegistration =\n | { kind: \"create\" }\n | { kind: \"adopt\"; app: ExistingApp; reason: AdoptionReason }\n | { kind: \"conflict\"; app: ExistingApp };\n\n/**\n * Whether to insert, return the existing row, or refuse.\n *\n * Adoption is what makes `capuchoo init` idempotent, which the model requires:\n * the identifier comes from the binary, so a second machine running init is\n * describing the same app rather than asking for a new one.\n */\nexport function decideAppRegistration(facts: AppRegistrationFacts): AppRegistration {\n const { existing, requestedOrganizationId } = facts;\n\n if (!existing) return { kind: \"create\" };\n\n if (existing.organization_id === requestedOrganizationId) {\n return { kind: \"adopt\", app: existing, reason: \"same-organisation\" };\n }\n\n if (facts.callerHasDirectPermission) {\n return { kind: \"adopt\", app: existing, reason: \"direct-permission\" };\n }\n\n // A row whose organisation no longer exists belongs to nobody, so claiming it\n // takes nothing from anyone. Without this the identifier is burned forever.\n if (existing.organization_id === null || facts.existingOrganizationExists === false) {\n return { kind: \"adopt\", app: existing, reason: \"orphaned\" };\n }\n\n return { kind: \"conflict\", app: existing };\n}\n\n/** Whether an adopted row should be moved into the requested organisation. */\nexport function adoptionReparents(reason: AdoptionReason): boolean {\n return reason === \"orphaned\";\n}\n\nexport function describeAppConflict(appId: string): string {\n return (\n `${appId} is already registered to another organisation. Bundle identifiers are ` +\n \"unique across Capuchoo, because a device reports only the id compiled into it. \" +\n \"Ask whoever owns it to release it, or change the applicationId.\"\n );\n}\n\nexport function describeAdoption(reason: AdoptionReason, appId: string): string {\n switch (reason) {\n case \"same-organisation\":\n return `${appId} already exists in this organisation - linking to it.`;\n case \"direct-permission\":\n return `${appId} already exists and you have access to it - linking to it.`;\n case \"orphaned\":\n return `${appId} existed without an owning organisation - claiming it.`;\n }\n}\n","/** App roles, weakest first. Index is the ordering. */\nexport const APP_ROLE_ORDER = [\"viewer\", \"tester\", \"developer\", \"admin\"] as const;\n\nexport type AppRole = (typeof APP_ROLE_ORDER)[number];\n\nexport function isAppRole(value: unknown): value is AppRole {\n return typeof value === \"string\" && (APP_ROLE_ORDER as readonly string[]).includes(value);\n}\n\n/** How much a role can do, for comparison only. */\nexport function roleRank(role: AppRole): number {\n return APP_ROLE_ORDER.indexOf(role);\n}\n\n/**\n * The role a credential actually grants: the weaker of what the account has and\n * what the key is capped at.\n *\n * A key is the account acting through a machine, so it can never grant more than\n * the account has - and a cap lets it grant less, which is what makes a CI\n * credential safe to hand out. An uncapped key (null) is the account's own role.\n */\nexport function effectiveRole(\n accountRole: AppRole | null | undefined,\n keyCap?: AppRole | null,\n): AppRole | null {\n if (!accountRole) return null;\n if (!keyCap) return accountRole;\n\n return roleRank(keyCap) < roleRank(accountRole) ? keyCap : accountRole;\n}\n\n/**\n * Whether a caller may mint a key capped at `requested`.\n *\n * A credential may never create one with more reach than itself, or a cap is not\n * a boundary - a developer key could mint an admin key and escalate. A caller\n * with no cap of its own (a dashboard session) may mint any.\n */\nexport function canIssueCap(\n callerCap: AppRole | null | undefined,\n requested: AppRole | null | undefined,\n): boolean {\n if (!callerCap) return true;\n // An uncapped key is stronger than any capped one, so it cannot be issued by\n // a capped caller.\n if (!requested) return false;\n\n return roleRank(requested) <= roleRank(callerCap);\n}\n\n/** One line describing what a cap allows, for a key listing. */\nexport function describeCap(cap: AppRole | null | undefined): string {\n if (!cap) return \"the account's own rights\";\n\n return cap === \"admin\" || cap === \"developer\"\n ? `${cap} - may publish`\n : `${cap} - may not publish`;\n}\n"],"mappings":"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"}
|