P4.6: install pages with accurate iOS install modes

The human install surface (subplan 04 Slice 5), honest per PLAN.md's
iOS Install Policy. App gains ios_mode (artifact_only default /
testflight / enterprise) and testflight_url; absent db.json members
load as defaults. dist app set is the admin mutator (apps are created
by publish), enforcing the policy preconditions as machine-readable
errors: testflight requires --testflight-url, enterprise requires
--ios-bundle-id. Every set appends an app.update audit event.

GET /install/<slug>/<channel> renders the channel's current release as
platform sections — the User-Agent's platform ordered first and marked
— with per-mode iOS actions: TestFlight link, itms-services OTA deep
link, or an IPA download explicitly labeled as not installable from
the page. Size + sha256 are visible on every artifact row.
GET /install/<slug>/<channel>/manifest.plist serves the enterprise OTA
manifest (bundle id, version, https package URL off the Host header)
and 404s in any other mode. The index links channels to their pages.

KNOWN sx BOUNDARY (issue 0098): an enum literal returned directly into
an optional target silently lowers to variant 0 — ua_platform routes
every variant through a typed local.

make test 19/19 (new: server_install.sx pinned acceptance).
This commit is contained in:
agra
2026-06-12 11:49:14 +03:00
parent 0a6fa65c58
commit aea3d62b60
7 changed files with 802 additions and 4 deletions

183
src/app/ops.sx Normal file
View File

@@ -0,0 +1,183 @@
// =====================================================================
// ops.sx (app) — `dist app set`: admin mutation of an app's install
// policy over the persisted store (P4.6, subplan 04 Slice 5).
//
// Apps are CREATED by publish (find-or-create on slug); `app set` only
// edits ones that exist. It updates display_name, the iOS install mode
// (artifact_only / testflight / enterprise), the TestFlight URL, and the
// iOS bundle id, then revalidates the whole App — so the iOS policy
// preconditions (testflight needs a URL, enterprise needs a bundle id)
// abort the set as loud, machine-readable errors instead of leaving a
// policy the install page cannot honor.
//
// FAILURE CONTRACT (as everywhere): every abort happens before `db.save`,
// so a failed set never changes db.json.
// =====================================================================
#import "modules/std.sx";
#import "modules/std/fs.sx";
#import "modules/std/json.sx";
#import "../domain/platform.sx";
#import "../domain/app.sx";
#import "../domain/release.sx";
#import "../domain/artifact.sx";
#import "../domain/channel.sx";
#import "../domain/token.sx";
#import "../domain/audit.sx";
#import "../domain/validate.sx";
#import "../repo/repo.sx";
db :: #import "../repo/db.sx";
jout :: #import "../json_out.sx";
pl :: #import "../publish/publish.sx";
AppOpError :: error {
Load, // db.json absent or unreadable
NotFound, // no app with that slug
Invalid, // the requested policy fails validation
Persist, // db.json could not be re-written
}
// What `dist app set` wants to change; empty string = leave unchanged.
AppSetRequest :: struct {
display_name: string = "";
ios_mode: string = ""; // variant name, parsed here
testflight_url: string = "";
ios_bundle_id: string = "";
}
AppSetOutcome :: struct {
app_id: string;
slug: string;
display_name: string;
ios_mode: string;
testflight_url: string;
ios_bundle_id: string;
}
app_invalid_code :: (e: ValidationErr) -> string {
if e == error.BadIosMode { return "app.bad_ios_mode"; }
if e == error.TestflightUrlRequired { return "app.testflight_url_required"; }
if e == error.BundleIdRequired { return "app.bundle_id_required"; }
return "app.invalid";
}
app_invalid_message :: (e: ValidationErr) -> string {
if e == error.BadIosMode { return "--ios-mode must be one of: artifact_only testflight enterprise"; }
if e == error.TestflightUrlRequired { return "ios_mode testflight requires --testflight-url"; }
if e == error.BundleIdRequired { return "ios_mode enterprise requires --ios-bundle-id"; }
return "app fails domain validation";
}
run_app_set :: (store_dir: string, slug: string, req: AppSetRequest, fail_out: *jout.CliFailure) -> (AppSetOutcome, !AppOpError) {
if !exists(path_join(store_dir, "db.json")) {
fail_out.code = "store.load";
fail_out.message = concat("no db.json under the store (nothing published yet): ", store_dir);
raise error.Load;
}
repo, le := db.load(store_dir);
if le {
fail_out.code = "store.load";
fail_out.message = concat("db.json under the store could not be loaded: ", store_dir);
raise error.Load;
}
aq := repo.find_app_by_slug(slug);
if aq == null {
fail_out.code = "app.unknown";
fail_out.message = concat("no app with that slug in the store (publish creates apps): ", slug);
raise error.NotFound;
}
a := aq!;
if req.display_name.len > 0 { a.display_name = req.display_name; }
if req.ios_mode.len > 0 {
m, me := parse_ios_mode(req.ios_mode);
if me {
fail_out.code = app_invalid_code(me);
fail_out.message = app_invalid_message(me);
raise error.Invalid;
}
a.ios_mode = m;
}
if req.testflight_url.len > 0 { a.testflight_url = req.testflight_url; }
if req.ios_bundle_id.len > 0 {
// replace-or-add the iOS bundle id (one per platform)
found := false;
i := 0;
while i < a.bundle_ids.len {
if a.bundle_ids.items[i].platform == .ios {
a.bundle_ids.items[i].value = req.ios_bundle_id;
found = true;
}
i += 1;
}
if !found {
a.bundle_ids.append(BundleId.{ platform = .ios, value = req.ios_bundle_id },
repo.own_allocator);
}
}
a.updated_at = pl.now_secs();
verr := false;
vcode := "";
vmsg := "";
validate_app(a) catch (e) {
verr = true;
vcode = app_invalid_code(e);
vmsg = app_invalid_message(e);
};
if verr {
fail_out.code = vcode;
fail_out.message = vmsg;
raise error.Invalid;
}
repo.update_app(a);
repo.create_audit_event(AuditEvent.{
id = concat(concat("evt-app-set-", a.id), concat("-", int_to_string(a.updated_at))),
actor = "cli", action = "app.update", target_type = "app",
target_id = a.id, metadata = "", created_at = a.updated_at,
});
werr := false;
db.save(@repo, store_dir) catch { werr = true; };
if werr {
fail_out.code = "persist.save";
fail_out.message = concat("db.json could not be written under the store: ", store_dir);
raise error.Persist;
}
return AppSetOutcome.{
app_id = a.id, slug = a.slug, display_name = a.display_name,
ios_mode = db.ios_mode_str(a.ios_mode),
testflight_url = a.testflight_url,
ios_bundle_id = bundle_id_for(a, .ios),
};
}
// `{"status":"updated","app":{id,slug,display_name,ios_mode,
// testflight_url,ios_bundle_id}}`.
write_app_set_json :: (o: *AppSetOutcome, dst: []u8) -> (i64, !JsonError) {
gpa := GPA.init();
root : Object = .{};
root.put("status", .str("updated"), xx gpa);
ao : Object = .{};
ao.put("id", .str(o.app_id), xx gpa);
ao.put("slug", .str(o.slug), xx gpa);
ao.put("display_name", .str(o.display_name), xx gpa);
ao.put("ios_mode", .str(o.ios_mode), xx gpa);
ao.put("testflight_url", .str(o.testflight_url), xx gpa);
ao.put("ios_bundle_id", .str(o.ios_bundle_id), xx gpa);
root.put("app", .object(ao), xx gpa);
rootv : Value = .object(root);
n := try write_to_buffer(rootv, dst);
return n;
}
app_set_human :: (o: *AppSetOutcome) -> string {
s := concat("updated app ", concat(o.slug, concat(" (", concat(o.display_name, ")\n"))));
s = concat(s, concat(" ios_mode: ", o.ios_mode));
if o.testflight_url.len > 0 { s = concat(s, concat(" testflight: ", o.testflight_url)); }
if o.ios_bundle_id.len > 0 { s = concat(s, concat(" ios bundle id: ", o.ios_bundle_id)); }
return concat(s, "\n");
}

View File

@@ -36,6 +36,7 @@ pl :: #import "publish/publish.sx";
rem :: #import "publish/remote.sx";
ops :: #import "release/ops.sx";
tops :: #import "token/ops.sx";
aops :: #import "app/ops.sx";
srv :: #import "server/distd.sx";
// Direct stderr writer (fd 2), so human help/usage/progress never lands on
@@ -51,7 +52,7 @@ emit_human :: (s: string, json_mode: bool) {
if json_mode { eputs(s); } else { out(s); }
}
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> publish into a local store directory, OR:\n --server <url> publish against a running distd (http://<ipv4-or-localhost>:<port>)\n --token <secret> bearer token for --server (mint with: dist token create)\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n server\n server run serve the store over HTTP (0.0.0.0)\n --local-store <dir> local artifact store + db.json directory\n --port <n> TCP port (default 8787)\n GET (public): / (HTML index), /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n POST (Bearer token, publish scope): /api/upload, /api/apps/<slug>/releases,\n /api/apps/<slug>/channels/<name>/promote, /api/apps/<slug>/channels/<name>/rollback\n token\n token create mint a scoped automation token (secret shown ONCE)\n --name <name> token name, [a-z0-9._-]\n --local-store <dir> local artifact store + db.json directory\n --scope <words> space-separated scopes: publish read (default: publish)\n --app <slug> restrict to one app (default: any)\n --channel <name> restrict to one channel (default: any)\n --expires-in <secs> lifetime in seconds (default: never expires)\n token list tokens with lifecycle status (never the secret)\n --local-store <dir> local artifact store + db.json directory\n token revoke revoke a token by id\n --id <token-id> token to revoke\n --local-store <dir> local artifact store + db.json directory\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback/token op aborted or server could not bind)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> publish into a local store directory, OR:\n --server <url> publish against a running distd (http://<ipv4-or-localhost>:<port>)\n --token <secret> bearer token for --server (mint with: dist token create)\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n server\n server run serve the store over HTTP (0.0.0.0)\n --local-store <dir> local artifact store + db.json directory\n --port <n> TCP port (default 8787)\n GET (public): / (HTML index), /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n POST (Bearer token, publish scope): /api/upload, /api/apps/<slug>/releases,\n /api/apps/<slug>/channels/<name>/promote, /api/apps/<slug>/channels/<name>/rollback\n app\n app set edit an existing app's display name / iOS install policy\n --app <slug> app to edit (apps are created by publish)\n --local-store <dir> local artifact store + db.json directory\n --display-name <s> new display name\n --ios-mode <m> artifact_only | testflight | enterprise\n --testflight-url <u> TestFlight link (required for testflight mode)\n --ios-bundle-id <id> iOS bundle identifier (required for enterprise mode)\n token\n token create mint a scoped automation token (secret shown ONCE)\n --name <name> token name, [a-z0-9._-]\n --local-store <dir> local artifact store + db.json directory\n --scope <words> space-separated scopes: publish read (default: publish)\n --app <slug> restrict to one app (default: any)\n --channel <name> restrict to one channel (default: any)\n --expires-in <secs> lifetime in seconds (default: never expires)\n token list tokens with lifecycle status (never the secret)\n --local-store <dir> local artifact store + db.json directory\n token revoke revoke a token by id\n --id <token-id> token to revoke\n --local-store <dir> local artifact store + db.json directory\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback/token op aborted or server could not bind)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
// True if `name` appears as a token in `args`.
has_flag :: (args: []string, name: string) -> bool {
@@ -327,6 +328,38 @@ handle_token_revoke :: (p: *Parsed, json_mode: bool) {
}
}
// `dist app set` — edit an existing app's display name and iOS install
// policy over the persisted store (P4.6). Rendering contract as above.
handle_app_set :: (p: *Parsed, json_mode: bool) {
req : aops.AppSetRequest = .{};
if p.is_set("display-name") { req.display_name = p.value_of("display-name"); }
if p.is_set("ios-mode") { req.ios_mode = p.value_of("ios-mode"); }
if p.is_set("testflight-url") { req.testflight_url = p.value_of("testflight-url"); }
if p.is_set("ios-bundle-id") { req.ios_bundle_id = p.value_of("ios-bundle-id"); }
fail : jout.CliFailure = .{};
o, e := aops.run_app_set(p.value_of("local-store"), p.value_of("app"), req, @fail);
if e {
report_failure("app set", fail, json_mode);
}
if !e {
if !json_mode {
out(aops.app_set_human(@o));
return;
}
eputs("dist: app set ok\n");
raw : [4096]u8 = ---;
werr := false;
n := aops.write_app_set_json(@o, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
if werr {
eputs("dist: internal error: JSON serialization failed\n");
exit_command_failed();
}
out(string.{ ptr = @raw[0], len = n });
out("\n");
}
}
// Positive decimal seconds (a token lifetime); anything else (empty,
// non-digits, zero, absurd length) is null — a usage error at the call
// site.
@@ -370,6 +403,7 @@ dispatch :: (p: *Parsed, json_mode: bool) {
if p.group == "token" and p.command == "create" { handle_token_create(p, json_mode); return; }
if p.group == "token" and p.command == "list" { handle_token_list(p, json_mode); return; }
if p.group == "token" and p.command == "revoke" { handle_token_revoke(p, json_mode); return; }
if p.group == "app" and p.command == "set" { handle_app_set(p, json_mode); return; }
eputs("dist: internal error: unrouted command\n");
exit_usage();
}
@@ -436,6 +470,14 @@ main :: () -> ! {
FlagSpec.{ name = "id", takes_value = true, required = true },
FlagSpec.{ name = "local-store", takes_value = true, required = true },
];
app_set_flags : []FlagSpec = .[
FlagSpec.{ name = "app", takes_value = true, required = true },
FlagSpec.{ name = "local-store", takes_value = true, required = true },
FlagSpec.{ name = "display-name", takes_value = true, required = false },
FlagSpec.{ name = "ios-mode", takes_value = true, required = false },
FlagSpec.{ name = "testflight-url", takes_value = true, required = false },
FlagSpec.{ name = "ios-bundle-id", takes_value = true, required = false },
];
cmds : []Command = .[
Command.{ group = "ci", command = "publish", flags = publish_flags },
Command.{ group = "release", command = "promote", flags = promote_flags },
@@ -444,6 +486,7 @@ main :: () -> ! {
Command.{ group = "token", command = "create", flags = token_create_flags },
Command.{ group = "token", command = "list", flags = token_list_flags },
Command.{ group = "token", command = "revoke", flags = token_revoke_flags },
Command.{ group = "app", command = "set", flags = app_set_flags },
];
diag : Diag = .{};

View File

@@ -17,6 +17,21 @@ BundleId :: struct {
value: string;
}
// How this app's iOS artifact may be installed (PLAN.md "iOS Install
// Policy"). A normal iPhone cannot install an arbitrary IPA from a web
// page, so the install surface must never imply it can:
// artifact_only — the IPA is downloadable, presented as bytes, never as
// an on-device install action (the safe default);
// testflight — installation happens through Apple's TestFlight flow
// (requires `testflight_url`);
// enterprise — enrolled devices install over-the-air via a signed
// HTTPS manifest plist (requires an iOS bundle id).
IosMode :: enum u8 {
artifact_only;
testflight;
enterprise;
}
// A distributable application. `slug` is the URL-safe handle; `id` is the
// opaque primary key. Timestamps are unix epoch seconds.
App :: struct {
@@ -26,6 +41,18 @@ App :: struct {
bundle_ids: List(BundleId);
owner: string;
visibility: Visibility = .private;
ios_mode: IosMode = .artifact_only;
testflight_url: string = "";
created_at: i64;
updated_at: i64;
}
// The app's bundle identifier for `platform`, "" when none is set.
bundle_id_for :: (a: App, p: Platform) -> string {
i := 0;
while i < a.bundle_ids.len {
if a.bundle_ids.items[i].platform == p { return a.bundle_ids.items[i].value; }
i += 1;
}
return "";
}

View File

@@ -21,6 +21,9 @@ ValidationErr :: error {
BadDigest, // sha256 was not exactly 64 lowercase-hex chars
BadTokenName, // token name empty or outside [a-z0-9._-]
BadScope, // scopes empty or a word outside the known scope set
BadIosMode, // ios mode id did not name an IosMode variant
TestflightUrlRequired, // ios_mode testflight without a testflight_url
BundleIdRequired, // ios_mode enterprise without an ios bundle id
}
// ── Character classes (ASCII byte codes) ────────────────────────────────
@@ -125,15 +128,32 @@ parse_platform :: (s: string) -> (Platform, !ValidationErr) {
// ── Aggregate validators (required-field presence + field rules) ─────────
// Parse an iOS install-mode id into the IosMode enum. Accepted ids are
// exactly the variant names; anything else -> BadIosMode.
parse_ios_mode :: (s: string) -> (IosMode, !ValidationErr) {
if s == "artifact_only" { return .artifact_only; }
if s == "testflight" { return .testflight; }
if s == "enterprise" { return .enterprise; }
raise error.BadIosMode;
}
// App requires id, slug, display_name, owner; slug must also pass
// validate_slug. Presence is checked first, so an empty slug surfaces as
// MissingField and a present-but-malformed slug as BadSlug.
// MissingField and a present-but-malformed slug as BadSlug. The iOS
// install policy must be satisfiable: testflight needs a URL to send the
// user to, enterprise needs the bundle id the OTA manifest declares.
validate_app :: (a: App) -> !ValidationErr {
if a.id.len == 0 { raise error.MissingField; }
if a.slug.len == 0 { raise error.MissingField; }
if a.display_name.len == 0 { raise error.MissingField; }
if a.owner.len == 0 { raise error.MissingField; }
try validate_slug(a.slug);
if a.ios_mode == .testflight and a.testflight_url.len == 0 {
raise error.TestflightUrlRequired;
}
if a.ios_mode == .enterprise and bundle_id_for(a, .ios).len == 0 {
raise error.BundleIdRequired;
}
return;
}

View File

@@ -64,6 +64,11 @@ policy_str :: (p: RolloutPolicy) -> string {
if p == .manual { return "manual"; }
return "percentage";
}
ios_mode_str :: (m: IosMode) -> string {
if m == .testflight { return "testflight"; }
if m == .enterprise { return "enterprise"; }
return "artifact_only";
}
status_str :: (s: ValidationStatus) -> string {
if s == .pending { return "pending"; }
if s == .valid { return "valid"; }
@@ -87,6 +92,11 @@ parse_policy :: (s: string) -> (RolloutPolicy, !LoadErr) {
if s == "percentage" { return .percentage; }
raise error.BadShape;
}
ios_mode_from :: (s: string) -> (IosMode, !LoadErr) {
m, e := parse_ios_mode(s); // reuse the domain parser
if e { raise error.BadShape; }
return m;
}
parse_status :: (s: string) -> (ValidationStatus, !LoadErr) {
if s == "pending" { return .pending; }
if s == "valid" { return .valid; }
@@ -113,6 +123,8 @@ app_to_json :: (a: App, alloc: Allocator) -> Value {
o.put("bundle_ids", .array(bids), alloc);
o.put("owner", .str(a.owner), alloc);
o.put("visibility", .str(visibility_str(a.visibility)), alloc);
o.put("ios_mode", .str(ios_mode_str(a.ios_mode)), alloc);
o.put("testflight_url", .str(a.testflight_url), alloc);
o.put("created_at", .int_(a.created_at), alloc);
o.put("updated_at", .int_(a.updated_at), alloc);
return .object(o);
@@ -331,6 +343,21 @@ app_from_json :: (o: Object, alloc: Allocator) -> (App, !LoadErr) {
}
a.owner = try req_str(o, "owner", alloc);
a.visibility = try parse_visibility(try req_str_view(o, "visibility"));
// OPTIONAL members (absent in db.json files from before the iOS
// install policy landed): default artifact_only / "". Present members
// are held to full strictness.
imq := db_obj_find(o, "ios_mode");
if imq != null {
imv := imq!;
if imv != .str { raise error.BadShape; }
a.ios_mode = try ios_mode_from(imv.str);
}
tfq := db_obj_find(o, "testflight_url");
if tfq != null {
tfv := tfq!;
if tfv != .str { raise error.BadShape; }
a.testflight_url = db_dup_str(tfv.str, alloc);
}
a.created_at = try req_int(o, "created_at");
a.updated_at = try req_int(o, "updated_at");
return a;

View File

@@ -12,6 +12,10 @@
// GET /api/apps/<slug> {"app":..,"releases":[..],"channels":[..]}
// GET /download/<sha256> the object's bytes (application/octet-stream,
// X-Checksum-SHA256 header)
// GET /install/<slug>/<channel> install page (UA-aware,
// iOS actions honest per the app's IosMode)
// GET /install/<slug>/<channel>/manifest.plist enterprise OTA manifest
// (404 unless ios_mode is enterprise)
//
// Writes require `Authorization: Bearer <token>` with the `publish` scope
// (src/server/auth.sx; tokens are minted with `dist token create`):
@@ -256,13 +260,14 @@ render_index :: (repo: *Repo) -> string {
app := repo.apps.items[a];
page = concat(page, concat("<h2>", concat(html_escape(app.slug), concat(" <small>", concat(html_escape(app.display_name), "</small></h2>")))));
// Channels: name -> current release pointer.
// Channels: name (linked to the install page) -> current release.
page = concat(page, "<table><tr><th>channel</th><th>current release</th></tr>");
c := 0;
while c < repo.channels.len {
ch := repo.channels.items[c];
if ch.app_id == app.id {
page = concat(page, concat("<tr><td>", concat(html_escape(ch.name), concat("</td><td>", concat(html_escape(ch.current_release_id), "</td></tr>")))));
link := concat("<a href=\"/install/", concat(html_escape(app.slug), concat("/", concat(html_escape(ch.name), concat("\">", concat(html_escape(ch.name), "</a>"))))));
page = concat(page, concat("<tr><td>", concat(link, concat("</td><td>", concat(html_escape(ch.current_release_id), "</td></tr>")))));
}
c += 1;
}
@@ -351,6 +356,276 @@ handle_download :: (client: i32, store_dir: string, sha: string) {
http.respond(client, 200, "application/octet-stream", extra, bq!);
}
// ── install pages (subplan 04 Slice 5) ────────────────────────────────
// True iff `needle` occurs in `hay` (UA sniffing; both are short).
contains :: (hay: string, needle: string) -> bool {
if needle.len == 0 { return true; }
if needle.len > hay.len { return false; }
i := 0;
while i + needle.len <= hay.len {
j := 0;
ok := true;
while j < needle.len {
if hay[i + j] != needle[j] { ok = false; break; }
j += 1;
}
if ok { return true; }
i += 1;
}
return false;
}
// The requesting device's platform, sniffed from the User-Agent. Order
// matters: iPhone UAs contain "like Mac OS X" and Android UAs contain
// "Linux", so the mobile checks run first. Null = no idea.
// Each variant goes through a TYPED LOCAL: an enum literal returned
// directly into the `?Platform` target silently lowers to variant 0
// (sx issue 0098).
ua_platform :: (ua: string) -> ?Platform {
p : Platform = .ios;
if contains(ua, "iPhone") or contains(ua, "iPad") or contains(ua, "iPod") { return p; }
if contains(ua, "Android") { p = .android_apk; return p; }
if contains(ua, "Macintosh") { p = .macos; return p; }
if contains(ua, "Windows") { p = .windows; return p; }
if contains(ua, "Linux") { p = .linux; return p; }
return null;
}
platform_label :: (p: Platform) -> string {
if p == .ios { return "iOS"; }
if p == .android_apk { return "Android"; }
if p == .macos { return "macOS"; }
if p == .linux { return "Linux"; }
return "Windows";
}
// The release the (app, channel) pair currently serves, resolving each
// step or answering the precise 404. Null = response already sent.
// `host` (the request's Host header) feeds the absolute URLs the
// enterprise install flow needs.
InstallCtx :: struct {
app: App;
channel_name: string;
release: Release;
host: string = "localhost";
}
resolve_install :: (client: i32, repo: *Repo, slug: string, chan_name: string) -> ?InstallCtx {
aq := repo.find_app_by_slug(slug);
if aq == null {
respond_error(client, 404, "install.unknown_app",
concat("no app with that slug in the store: ", slug));
return null;
}
app := aq!;
cq := repo.get_channel(app.id, chan_name);
if cq == null {
respond_error(client, 404, "install.unknown_channel",
concat("the app has no channel with that name: ", chan_name));
return null;
}
rq := repo.get_release(cq!.current_release_id);
if rq == null {
respond_error(client, 404, "install.no_release",
concat("the channel does not point at a published release: ", chan_name));
return null;
}
return InstallCtx.{ app = app, channel_name = chan_name, release = rq! };
}
// One artifact row: filename (download link), size, full sha256 — the
// digest is part of the contract, not decoration.
install_artifact_row :: (art: Artifact, label: string) -> string {
row := concat("<p class=\"artifact\"><a href=\"/download/", concat(art.sha256, "\">"));
row = concat(row, concat(html_escape(art.filename), "</a>"));
if label.len > 0 { row = concat(row, concat(" <small>", concat(label, "</small>"))); }
row = concat(row, concat(" <small>", concat(int_to_string(art.size_bytes), " bytes</small>")));
row = concat(row, concat("<br><small class=\"sha\">sha256 ", concat(art.sha256, "</small></p>")));
return row;
}
// The iOS section body for the app's install mode — the honest version
// of "install on iPhone" (see IosMode in the domain).
render_ios_actions :: (ctx: *InstallCtx, art: Artifact) -> string {
app := ctx.app;
if app.ios_mode == .testflight {
s := concat("<p><a class=\"action\" href=\"", concat(html_escape(app.testflight_url), "\">Open in TestFlight</a></p>"));
s = concat(s, "<p><small>Installation runs through Apple&#39;s TestFlight flow.</small></p>");
return concat(s, install_artifact_row(art, "(IPA artifact)"));
}
if app.ios_mode == .enterprise {
plist := concat("https://", concat(ctx.host, concat("/install/", concat(app.slug, concat("/", concat(ctx.channel_name, "/manifest.plist"))))));
itms := concat("itms-services://?action=download-manifest&url=", plist);
s := concat("<p><a class=\"action\" href=\"", concat(html_escape(itms), "\">Install on enrolled device</a></p>"));
s = concat(s, "<p><small>Over-the-air enterprise install; requires MDM enrollment and HTTPS.</small></p>");
return concat(s, install_artifact_row(art, "(IPA artifact)"));
}
// artifact_only — a download, never an install action
s := install_artifact_row(art, "");
return concat(s, "<p><small>IPA artifact download only &mdash; it cannot be installed on an iPhone or iPad from this page.</small></p>");
}
INSTALL_HEAD :: "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>install</title><style>body{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#101216;color:#d6dae1;margin:2rem auto;max-width:40rem;padding:0 1rem}h1{font-size:1.1rem}h2{font-size:1rem;margin-top:1.6rem;border-bottom:1px solid #2a2f3a;padding-bottom:.3rem}a{color:#7ab7ff;text-decoration:none}a:hover{text-decoration:underline}a.action{display:inline-block;border:1px solid #7ab7ff;border-radius:4px;padding:.4rem .8rem;margin:.2rem 0}small,.dim{color:#737b8c}.sha{word-break:break-all}.detected h2{color:#9fe09f}</style></head><body>";
// The install page: the channel's current release, one section per
// platform artifact, the requester's platform first and marked.
render_install :: (ctx: *InstallCtx, repo: *Repo, detected: ?Platform) -> string {
app := ctx.app;
rel := ctx.release;
page := INSTALL_HEAD;
page = concat(page, concat("<h1>", concat(html_escape(app.display_name), concat(" <small>", concat(html_escape(rel.version), concat(" &middot; ", concat(html_escape(ctx.channel_name), "</small></h1>")))))));
// platform sections in fixed order — except the detected platform,
// which moves to the front
order : [5]Platform = .[ .ios, .android_apk, .macos, .linux, .windows ];
if detected != null {
d := detected!;
order[0] = d;
slot := 1;
j := 0;
while j < 5 {
cand : Platform = .ios;
if j == 1 { cand = .android_apk; }
if j == 2 { cand = .macos; }
if j == 3 { cand = .linux; }
if j == 4 { cand = .windows; }
if cand != d {
order[slot] = cand;
slot += 1;
}
j += 1;
}
}
k := 0;
while k < 5 {
p := order[k];
// the release's artifact for this platform, if any
ai := 0;
while ai < repo.artifacts.len {
art := repo.artifacts.items[ai];
if art.release_id == rel.id and art.platform == p {
marked := detected != null and k == 0;
if marked { page = concat(page, "<section class=\"detected\">"); }
else { page = concat(page, "<section>"); }
page = concat(page, concat("<h2>", platform_label(p)));
if marked { page = concat(page, " <small>your device</small>"); }
page = concat(page, "</h2>");
if p == .ios {
page = concat(page, render_ios_actions(ctx, art));
} else {
label := "";
if p == .android_apk { label = "(APK)"; }
page = concat(page, install_artifact_row(art, label));
}
page = concat(page, "</section>");
}
ai += 1;
}
k += 1;
}
page = concat(page, "<p class=\"dim\"><small><a href=\"/\">&larr; all apps</a></small></p>");
return concat(page, "</body></html>");
}
handle_install_page :: (client: i32, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
rq := load_or_503(client, store_dir);
if rq == null { return; }
repo := rq!;
ctxq := resolve_install(client, @repo, slug, chan_name);
if ctxq == null { return; }
ctx := ctxq!;
ua := "";
uq := http.header_value(req.headers, "user-agent");
if uq != null { ua = uq!; }
hostq := http.header_value(req.headers, "host");
if hostq != null { ctx.host = hostq!; }
http.respond(client, 200, "text/html; charset=utf-8", "",
render_install(@ctx, @repo, ua_platform(ua)));
}
// The enterprise OTA manifest: Apple's plist shape, with the software
// package URL pointing back at this server over HTTPS (itms-services
// refuses plain http; TLS terminates at the reverse proxy).
handle_manifest_plist :: (client: i32, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
rq := load_or_503(client, store_dir);
if rq == null { return; }
repo := rq!;
ctxq := resolve_install(client, @repo, slug, chan_name);
if ctxq == null { return; }
ctx := ctxq!;
if ctx.app.ios_mode != .enterprise {
respond_error(client, 404, "install.not_enterprise",
"the app's iOS install mode is not enterprise; no OTA manifest exists");
return;
}
bid := bundle_id_for(ctx.app, .ios);
if bid.len == 0 {
respond_error(client, 404, "install.no_bundle_id",
"the app has no iOS bundle id; set one with: dist app set --ios-bundle-id");
return;
}
// the release's ios artifact
found := false;
art : Artifact = .{};
ai := 0;
while ai < repo.artifacts.len {
a := repo.artifacts.items[ai];
if a.release_id == ctx.release.id and a.platform == .ios { art = a; found = true; break; }
ai += 1;
}
if !found {
respond_error(client, 404, "install.no_ios_artifact",
"the channel's current release carries no iOS artifact");
return;
}
host := "localhost";
hostq := http.header_value(req.headers, "host");
if hostq != null { host = hostq!; }
url := concat("https://", concat(host, concat("/download/", art.sha256)));
x := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\"><dict><key>items</key><array><dict>";
x = concat(x, "<key>assets</key><array><dict><key>kind</key><string>software-package</string><key>url</key><string>");
x = concat(x, html_escape(url));
x = concat(x, "</string></dict></array><key>metadata</key><dict><key>bundle-identifier</key><string>");
x = concat(x, html_escape(bid));
x = concat(x, "</string><key>bundle-version</key><string>");
x = concat(x, html_escape(ctx.release.version));
x = concat(x, "</string><key>kind</key><string>software</string><key>title</key><string>");
x = concat(x, html_escape(ctx.app.display_name));
x = concat(x, "</string></dict></dict></array></dict></plist>\n");
http.respond(client, 200, "application/xml", "", x);
}
// GET /install/<slug>/<channel>[/manifest.plist]
handle_install_route :: (client: i32, store_dir: string, req: *http.Request, tail: string) {
s1 := seg_split(tail);
if s1.head.len == 0 or s1.rest.len == 0 {
respond_error(client, 404, "http.not_found",
"install pages live at /install/<slug>/<channel>");
return;
}
s2 := seg_split(s1.rest);
if s2.rest.len == 0 {
handle_install_page(client, store_dir, req, s1.head, s2.head);
return;
}
if s2.rest == "manifest.plist" {
handle_manifest_plist(client, store_dir, req, s1.head, s2.head);
return;
}
respond_error(client, 404, "http.not_found",
concat("no install route for ", tail));
}
// ── write surface (POST, token-gated) ─────────────────────────────────
// `s` split at its first '/': head before it, rest after it ("" when no
@@ -703,6 +978,10 @@ route :: (client: i32, store_dir: string, req: *http.Request) -> i64 {
handle_download(client, store_dir, tail_after(path, "/download/"));
return 200;
}
if starts_with(path, "/install/") {
handle_install_route(client, store_dir, req, tail_after(path, "/install/"));
return 200;
}
respond_error(client, 404, "http.not_found",
concat("no route for ", path));
return 404;

219
tests/server_install.sx Normal file
View File

@@ -0,0 +1,219 @@
// Pinned acceptance for P4.6 — install pages with accurate iOS modes
// (subplan 04 Slice 5; PLAN.md "iOS Install Policy").
//
// Publishes the APK+IPA example into a fresh store, starts the BUILT
// server, and walks `dist app set` through the three iOS modes asserting
// the page and OTA manifest at each step:
//
// * default (artifact_only): the IPA is a labeled download that
// explicitly does NOT claim on-device install; manifest.plist is 404
// (install.not_enterprise). The APK row shows its sha256.
// * testflight: setting the mode without a URL exits 1
// (app.testflight_url_required); with one, the page links TestFlight.
// * enterprise: without a bundle id exits 1 (app.bundle_id_required);
// with one, the page carries the itms-services deep link and
// manifest.plist serves XML naming bundle id, version, and an
// https://<host>/download/<sha> package URL. Flipping the mode back
// 404s the plist again — all without restarting the server.
// * UA detection: an iPhone UA marks the iOS section, an Android UA
// the Android section.
// * 404s: unknown slug/channel, channel-less path; the index links
// every channel to its install page.
#import "modules/std.sx";
#import "modules/std/json.sx";
process :: #import "modules/std/process.sx";
fs :: #import "modules/std/fs.sx";
hash :: #import "modules/std/hash.sx";
STORE :: ".sx-tmp/server_install";
PORT :: "18797";
BASE :: "http://127.0.0.1:18797";
get :: (o: Object, key: string) -> Value {
i := 0;
while i < o.len {
if o.items[i].key == key { return o.items[i].val; }
i += 1;
}
process.assert(false, concat("missing json key: ", key));
dummy : Value = .null_;
return dummy;
}
get_str :: (o: Object, key: string) -> string { return get(o, key).str; }
get_obj :: (o: Object, key: string) -> Object { return get(o, key).object; }
contains :: (hay: string, needle: string) -> bool {
if needle.len == 0 { return true; }
if needle.len > hay.len { return false; }
i := 0;
while i + needle.len <= hay.len {
j := 0;
ok := true;
while j < needle.len {
if hay[i + j] != needle[j] { ok = false; break; }
j += 1;
}
if ok { return true; }
i += 1;
}
return false;
}
parse_body :: (body: string, what: string, scratch: Allocator) -> Object {
v, e := parse(body, scratch);
if e {
process.assert(false, concat("must be valid JSON: ", what));
dummy : Object = .{};
return dummy;
}
return v.object;
}
fetch :: (path: string) -> string {
r := process.run(concat(concat("curl -s -m 2 ", BASE), path));
process.assert(r != null, concat("curl spawn failed: ", path));
return r!.stdout;
}
fetch_code :: (path: string) -> string {
r := process.run(concat(concat("curl -s -m 2 -o /dev/null -w '%{http_code}' ", BASE), path));
process.assert(r != null, concat("curl spawn failed: ", path));
return r!.stdout;
}
fetch_ua :: (path: string, ua: string) -> string {
cmd := concat("curl -s -m 2 -A '", concat(ua, "' "));
r := process.run(concat(cmd, concat(BASE, path)));
process.assert(r != null, concat("curl-ua spawn failed: ", path));
return r!.stdout;
}
// Run `dist app set` with extra `flags`; returns the run result.
app_set :: (flags: string) -> ?process.ProcessResult {
cmd := "build/dist app set --app acme-app --local-store .sx-tmp/server_install ";
return process.run(concat(cmd, concat(flags, " --json 2>/dev/null")));
}
assert_set_fails :: (r: ?process.ProcessResult, code: string, what: string, scratch: Allocator) {
process.assert(r != null, concat("spawn failed: ", what));
process.assert(r!.exit_code == 1, concat("must exit 1: ", what));
o := parse_body(r!.stdout, what, scratch);
process.assert(get_str(get_obj(o, "error"), "code") == code,
concat(concat("error code must be ", code), concat(": ", what)));
}
// sha256 of a fixture file as a heap string.
file_sha :: (path: string) -> string {
b := fs.read_file(path);
process.assert(b != null, concat("fixture must be readable: ", path));
d := hash.sha256_hex(b!);
v := string.{ ptr = @d[0], len = 64 };
return substr(v, 0, 64);
}
main :: () -> i32 {
gpa := GPA.init();
arena := Arena.init(xx gpa, 1 << 20);
defer arena.deinit();
process.run("pkill -f 'dist server run --local-store .sx-tmp/server_install' 2>/dev/null");
process.run(concat("rm -rf ", STORE));
pub := process.run("build/dist ci publish --manifest examples/dist.json --local-store .sx-tmp/server_install --json 2>/dev/null >/dev/null");
process.assert(pub != null and pub!.exit_code == 0, "publish must exit 0");
apk_sha := file_sha("examples/fixtures/acme-1.2.3-android.apk");
ipa_sha := file_sha("examples/fixtures/acme-1.2.3-ios.ipa");
sp := process.run(concat(concat(concat("sh -c 'build/dist server run --local-store ", STORE), concat(concat(" --port ", PORT), " >/dev/null 2>&1 & echo $!'")), ""));
process.assert(sp != null, "server spawn failed");
pid := sp!.stdout;
ready := false;
tries := 0;
while tries < 50 {
if fetch_code("/healthz") == "200" { ready = true; break; }
process.run("sleep 0.2");
tries += 1;
}
process.assert(ready, "server must answer /healthz within 10s");
print(" server up\n");
PAGE :: "/install/acme-app/stable";
PLIST :: "/install/acme-app/stable/manifest.plist";
// ── default: artifact_only, honest labeling ───────────────────────
process.assert(fetch_code(PAGE) == "200", "install page is 200");
page := fetch(PAGE);
process.assert(contains(page, "cannot be installed on an iPhone"),
"artifact_only page disclaims on-device install");
process.assert(!contains(page, "itms-services") and !contains(page, "TestFlight"),
"artifact_only page offers no install action");
process.assert(contains(page, apk_sha), "APK sha256 is visible on the page");
process.assert(contains(page, concat("/download/", ipa_sha)), "IPA is downloadable");
process.assert(fetch_code(PLIST) == "404", "plist is 404 outside enterprise mode");
pe := parse_body(fetch(PLIST), "plist error", xx arena);
process.assert(get_str(get_obj(pe, "error"), "code") == "install.not_enterprise",
"plist names install.not_enterprise");
print(" artifact_only: labeled honestly, sha visible, no plist\n");
// ── testflight ─────────────────────────────────────────────────────
assert_set_fails(app_set("--ios-mode testflight"), "app.testflight_url_required",
"testflight without url", xx arena);
tf := app_set("--ios-mode testflight --testflight-url https://testflight.apple.com/join/abc123");
process.assert(tf != null and tf!.exit_code == 0, "testflight set must exit 0");
page2 := fetch(PAGE);
process.assert(contains(page2, "Open in TestFlight"), "testflight page links TestFlight");
process.assert(contains(page2, "https://testflight.apple.com/join/abc123"), "page carries the TestFlight URL");
print(" testflight: precondition gated, page links Apple's flow\n");
// ── enterprise ─────────────────────────────────────────────────────
assert_set_fails(app_set("--ios-mode enterprise"), "app.bundle_id_required",
"enterprise without bundle id", xx arena);
en := app_set("--ios-mode enterprise --ios-bundle-id co.acme.app");
process.assert(en != null and en!.exit_code == 0, "enterprise set must exit 0");
page3 := fetch(PAGE);
process.assert(contains(page3, "itms-services://"), "enterprise page carries the OTA deep link");
process.assert(fetch_code(PLIST) == "200", "enterprise plist is 200");
plist := fetch(PLIST);
process.assert(contains(plist, "<key>bundle-identifier</key><string>co.acme.app</string>"),
"plist names the bundle id");
process.assert(contains(plist, "<key>bundle-version</key><string>1.2.3</string>"),
"plist names the release version");
process.assert(contains(plist, concat("https://127.0.0.1:18797/download/", ipa_sha)),
"plist package URL is https and content-addressed");
print(" enterprise: plist serves bundle id, version, https package URL\n");
// mode change is visible without a restart (per-request reload)
back := app_set("--ios-mode testflight");
process.assert(back != null and back!.exit_code == 0, "flip back to testflight must exit 0");
process.assert(fetch_code(PLIST) == "404", "plist 404s again after leaving enterprise mode");
print(" mode flips apply without restart\n");
// ── UA detection ───────────────────────────────────────────────────
ipage := fetch_ua(PAGE, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)");
process.assert(contains(ipage, "<section class=\"detected\"><h2>iOS"),
"iPhone UA marks the iOS section");
apage := fetch_ua(PAGE, "Mozilla/5.0 (Linux; Android 14; Pixel 8)");
process.assert(contains(apage, "<section class=\"detected\"><h2>Android"),
"Android UA marks the Android section");
print(" UA detection: device platform marked first\n");
// ── 404s + index links ─────────────────────────────────────────────
process.assert(fetch_code("/install/nope/stable") == "404", "unknown slug is 404");
ue := parse_body(fetch("/install/nope/stable"), "unknown slug", xx arena);
process.assert(get_str(get_obj(ue, "error"), "code") == "install.unknown_app", "names install.unknown_app");
ce := parse_body(fetch("/install/acme-app/nope"), "unknown channel", xx arena);
process.assert(get_str(get_obj(ce, "error"), "code") == "install.unknown_channel", "names install.unknown_channel");
process.assert(fetch_code("/install/acme-app") == "404", "channel-less install path is 404");
idx := fetch("/");
process.assert(contains(idx, "href=\"/install/acme-app/stable\""), "index links the install page");
print(" 404s precise; index links install pages\n");
// ── teardown ───────────────────────────────────────────────────────
process.run(concat("kill ", pid));
process.run(concat("rm -rf ", STORE));
print("server_install: ALL CASES PASS\n");
return 0;
}