@limrun/ui 0.14.0-rc.2 → 0.14.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/app-store-relay/index.cjs +3 -3
  2. package/dist/app-store-relay/index.js +5 -5
  3. package/dist/app-store-relay/react.cjs +1 -1
  4. package/dist/app-store-relay/react.d.ts +1 -1
  5. package/dist/app-store-relay/react.js +34 -31
  6. package/dist/apple-credentials/index.cjs +1 -0
  7. package/dist/apple-credentials/index.d.ts +59 -0
  8. package/dist/apple-credentials/index.js +227 -0
  9. package/dist/browser-storage-BnO-Ma-e.mjs +219 -0
  10. package/dist/browser-storage-DLxIqZk5.js +1 -0
  11. package/dist/client-D1drDXxE.js +1 -0
  12. package/dist/{client-CBX-hLnL.mjs → client-DSuNtWDv.mjs} +2 -7
  13. package/dist/core/device-install/operations/operations.d.ts +1 -1
  14. package/dist/core/device-install/storage/browser-storage.d.ts +8 -0
  15. package/dist/core/device-install/storage/index.d.ts +1 -0
  16. package/dist/core/device-install/storage/secret-store.d.ts +101 -0
  17. package/dist/core/device-install/types.d.ts +6 -0
  18. package/dist/core/errors.d.ts +1 -0
  19. package/dist/core/play-publish/google.d.ts +13 -0
  20. package/dist/core/play-publish/publish.d.ts +34 -0
  21. package/dist/device-build/index.cjs +1 -1
  22. package/dist/device-build/index.js +25 -24
  23. package/dist/device-build/react.cjs +1 -1
  24. package/dist/device-build/react.js +42 -43
  25. package/dist/device-install/index.cjs +1 -1
  26. package/dist/device-install/index.js +164 -160
  27. package/dist/device-install/react.cjs +1 -1
  28. package/dist/device-install/react.js +61 -60
  29. package/dist/errors-BSkqS7U-.js +1 -0
  30. package/dist/errors-gy8yQJos.mjs +6 -0
  31. package/dist/gsa-srp-Bme3JLEi.mjs +266 -0
  32. package/dist/gsa-srp-CCIfUz7Y.js +1 -0
  33. package/dist/index-ChhtuQ7e.js +22 -0
  34. package/dist/index-YDu8K9a4.mjs +11279 -0
  35. package/dist/play-publish/index.cjs +1 -0
  36. package/dist/play-publish/index.d.ts +2 -0
  37. package/dist/play-publish/index.js +109 -0
  38. package/dist/play-publish/react.cjs +1 -0
  39. package/dist/play-publish/react.d.ts +31 -0
  40. package/dist/play-publish/react.js +91 -0
  41. package/dist/{provisioning-DKbkBO-p.mjs → provisioning-B_DefBEp.mjs} +9 -9
  42. package/dist/{provisioning-Va0PH4Kk.js → provisioning-BlQMccX_.js} +1 -1
  43. package/package.json +18 -2
  44. package/src/app-store-relay/index.test.ts +6 -6
  45. package/src/app-store-relay/react.ts +6 -5
  46. package/src/apple-credentials/index.test.ts +158 -0
  47. package/src/apple-credentials/index.ts +241 -0
  48. package/src/core/device-install/apple/client.ts +1 -6
  49. package/src/core/device-install/apple/relay.ts +1 -4
  50. package/src/core/device-install/operations/operations.ts +8 -5
  51. package/src/core/device-install/storage/browser-storage.test.ts +65 -0
  52. package/src/core/device-install/storage/browser-storage.ts +33 -0
  53. package/src/core/device-install/storage/index.ts +1 -0
  54. package/src/core/device-install/storage/secret-store.ts +298 -0
  55. package/src/core/device-install/types.ts +6 -0
  56. package/src/core/errors.ts +3 -0
  57. package/src/core/play-publish/google.ts +113 -0
  58. package/src/core/play-publish/publish.test.ts +68 -0
  59. package/src/core/play-publish/publish.ts +89 -0
  60. package/src/device-build/react.ts +1 -4
  61. package/src/device-install/demo/demo.tsx +2 -8
  62. package/src/device-install/react.ts +31 -31
  63. package/src/play-publish/README.md +66 -0
  64. package/src/play-publish/index.ts +12 -0
  65. package/src/play-publish/react.ts +208 -0
  66. package/vite.config.ts +3 -0
  67. package/dist/browser-storage-Cx9eW-bm.mjs +0 -205
  68. package/dist/browser-storage-jJaIht4K.js +0 -1
  69. package/dist/client-BtC4n_qf.js +0 -1
  70. package/dist/index-CMeQfhYy.js +0 -22
  71. package/dist/index-DFdCvi2_.mjs +0 -11543
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Headless helpers that turn an authenticated Apple relay session into
3
+ * signing credentials persisted in a SigningSecretStore. The Apple relay
4
+ * only proxies: certificate and profile bytes always land in the browser
5
+ * first, and the browser writes them into the configured store (Limrun's
6
+ * org secret store by default, or a customer-provided one).
7
+ */
8
+ import {
9
+ createAppleCertificate,
10
+ downloadAppleCertificate,
11
+ downloadAppleProfile,
12
+ exportAppleCertificateP12,
13
+ generateAppleSigningKeyAndCSR,
14
+ listAppleCertificates,
15
+ listAppleProfiles,
16
+ } from '../app-store-relay';
17
+ import type { AppleRelayWebSocketClient } from '../core/device-install/apple';
18
+ import {
19
+ normalizeCertificateSerial,
20
+ parseProvisioningProfileBase64,
21
+ } from '../core/device-install/storage/browser-storage';
22
+ import {
23
+ APPLE_CERTIFICATE_SECRET_TYPE,
24
+ putAppleCertificateSecret,
25
+ putAppleProvisioningProfileSecret,
26
+ type AppleCertificateSecretData,
27
+ type AppleCertificateType,
28
+ type SigningSecret,
29
+ type SigningSecretStore,
30
+ } from '../core/device-install/storage/secret-store';
31
+
32
+ export {
33
+ APPLE_CERTIFICATE_SECRET_TYPE,
34
+ APPLE_PROVISIONING_PROFILE_SECRET_TYPE,
35
+ createBrowserSecretStore,
36
+ createLimrunSecretStore,
37
+ putAppleCertificateSecret,
38
+ putAppleProvisioningProfileSecret,
39
+ type AppleCertificateSecretData,
40
+ type AppleCertificateType,
41
+ type AppleProvisioningProfileSecretData,
42
+ type LimrunSecretStoreOptions,
43
+ type SigningSecret,
44
+ type SigningSecretData,
45
+ type SigningSecretMetadata,
46
+ type SigningSecretStore,
47
+ type SigningSecretType,
48
+ } from '../core/device-install/storage/secret-store';
49
+ export { normalizeCertificateSerial } from '../core/device-install/storage/browser-storage';
50
+
51
+ /**
52
+ * Conventional secret name of a team's certificate bundle. One bundle per
53
+ * team and Apple certificate type; the type is in the name so development
54
+ * and distribution material never collide.
55
+ */
56
+ export function appleCertificateSecretName(teamId: string, certificateType: AppleCertificateType) {
57
+ return `${teamId}/${certificateType}`;
58
+ }
59
+
60
+ export type EnsureAppleCertificateInput = {
61
+ relay: AppleRelayWebSocketClient;
62
+ teamId: string;
63
+ secretStore: SigningSecretStore;
64
+ /** Common name used when minting a new certificate. */
65
+ commonName?: string;
66
+ log?: (message: string, detail?: string) => void;
67
+ };
68
+
69
+ export type EnsureAppleCertificateResult = {
70
+ secret: SigningSecret;
71
+ certificateId: string;
72
+ /** True when a new certificate was minted instead of reusing the stored one. */
73
+ created: boolean;
74
+ };
75
+
76
+ /**
77
+ * Returns a usable development certificate secret for the team, reusing the
78
+ * stored one when its certificate is still on the team and minting a new
79
+ * one otherwise. Apple caps development certificates at 2 and never returns
80
+ * private keys, so reuse of the stored p12 is strongly preferred.
81
+ *
82
+ * Durability of the stored material is the secret store's concern:
83
+ * implementors who want retries or fallbacks build them into their
84
+ * SigningSecretStore.
85
+ */
86
+ export async function ensureAppleCertificateSecret({
87
+ relay,
88
+ teamId,
89
+ secretStore,
90
+ commonName,
91
+ log = () => {},
92
+ }: EnsureAppleCertificateInput): Promise<EnsureAppleCertificateResult> {
93
+ // The browser flow mints via the portal's development kind, which is
94
+ // the DEVELOPMENT certificate type in Apple's App Store Connect enum.
95
+ const certificateType: AppleCertificateType = 'DEVELOPMENT';
96
+ const secretName = appleCertificateSecretName(teamId, certificateType);
97
+ const current = await listAppleCertificates({ relay, teamId, certificateKind: 'development' });
98
+ const findOnTeam = (certificateId: string | undefined) =>
99
+ certificateId === undefined ? undefined : (
100
+ current.find(
101
+ (item) =>
102
+ stringField(item, 'certificateId') === certificateId ||
103
+ stringField(item, 'certRequestId') === certificateId,
104
+ )
105
+ );
106
+
107
+ const stored = await secretStore.get(APPLE_CERTIFICATE_SECRET_TYPE, secretName);
108
+ const storedCertificateId = stringField(stored?.data, 'certificateID');
109
+ if (stored && storedCertificateId && stored.data.certificateP12Base64) {
110
+ // The stored value may be a certRequestId; resolve the canonical
111
+ // certificateId the profile API expects. If the cert is no longer on
112
+ // the team (revoked), fall through and mint a new one.
113
+ const matched = findOnTeam(storedCertificateId);
114
+ if (matched) {
115
+ const certificateId = stringField(matched, 'certificateId') ?? storedCertificateId;
116
+ log('Reusing stored Apple certificate', certificateId);
117
+ return { secret: stored, certificateId, created: false };
118
+ }
119
+ log('Stored certificate is no longer on the team', 'Minting a new one.');
120
+ }
121
+
122
+ // The private key is generated and kept in this browser; Apple only ever
123
+ // sees the CSR. Without this key the downloaded cert cannot sign anything.
124
+ const key = await generateAppleSigningKeyAndCSR({ commonName: commonName ?? `Limrun ${teamId}` });
125
+ const certificate = await createAppleCertificate({
126
+ relay,
127
+ teamId,
128
+ certificateKind: 'development',
129
+ csrPEM: key.csrPEM,
130
+ });
131
+ const certificateId =
132
+ stringField(certificate, 'certificateId') ?? stringField(certificate, 'certRequestId');
133
+ if (!certificateId) {
134
+ throw new Error('Apple certificate creation did not return a certificate ID.');
135
+ }
136
+ const downloaded = await downloadAppleCertificate({
137
+ relay,
138
+ teamId,
139
+ certificateKind: 'development',
140
+ certificateId,
141
+ });
142
+ if (!downloaded.rawBodyBase64) {
143
+ throw new Error('Apple certificate download returned no bytes.');
144
+ }
145
+ const data: AppleCertificateSecretData = {
146
+ certificateP12Base64: exportAppleCertificateP12({
147
+ privateKeyPKCS8Base64: key.privateKeyPKCS8Base64,
148
+ certificateBase64: downloaded.rawBodyBase64,
149
+ password: '',
150
+ friendlyName: `Apple Development ${teamId}`,
151
+ }),
152
+ certificateType,
153
+ teamID: teamId,
154
+ certificateID: certificateId,
155
+ // Normalized so it equals the serials profiles reference.
156
+ serialNumber: normalizeCertificateSerial(stringField(certificate, 'serialNum')),
157
+ expirationDate: stringField(certificate, 'expirationDateString'),
158
+ };
159
+
160
+ let secret: SigningSecret;
161
+ try {
162
+ secret = await putAppleCertificateSecret(secretStore, secretName, data);
163
+ } catch (error) {
164
+ // The key existed only in this browser's memory; once this throw
165
+ // unwinds it is gone, and the portal certificate is unusable.
166
+ throw new Error(
167
+ `Certificate ${certificateId} was created on the Apple Developer portal but saving its private ` +
168
+ `key to the secret store failed: ${errorText(error)}. The key cannot be recovered; revoke ` +
169
+ `certificate ${certificateId} at developer.apple.com before retrying, or the retry will mint ` +
170
+ `another certificate against Apple's limit.`,
171
+ );
172
+ }
173
+ log('Apple certificate stored', certificateId);
174
+ return { secret, certificateId, created: true };
175
+ }
176
+
177
+ function errorText(error: unknown) {
178
+ return error instanceof Error ? error.message : String(error);
179
+ }
180
+
181
+ export type SaveAppleProfileInput = {
182
+ relay: AppleRelayWebSocketClient;
183
+ teamId: string;
184
+ profileId: string;
185
+ secretStore: SigningSecretStore;
186
+ log?: (message: string, detail?: string) => void;
187
+ };
188
+
189
+ /**
190
+ * Downloads the provisioning profile and stores it as an
191
+ * appleProvisioningProfile secret named `${teamId}/${uuid}`. The UUID is
192
+ * unique per profile, so a team can hold many profiles for the same
193
+ * bundle ID and certificate set; the reference fields (certificate
194
+ * serials, bundle IDs, device IDs parsed out of the profile) are what
195
+ * consumers filter on.
196
+ */
197
+ export async function saveAppleProfileSecret({
198
+ relay,
199
+ teamId,
200
+ profileId,
201
+ secretStore,
202
+ log = () => {},
203
+ }: SaveAppleProfileInput): Promise<SigningSecret> {
204
+ const downloaded = await downloadAppleProfile({ relay, teamId, profileId });
205
+ const profileBase64 = downloaded.rawBodyBase64;
206
+ if (!profileBase64) {
207
+ throw new Error('Apple provisioning profile download returned no bytes.');
208
+ }
209
+ const info = parseProvisioningProfileBase64(profileBase64);
210
+ const name = `${teamId}/${info.uuid ?? profileId}`;
211
+ const secret = await putAppleProvisioningProfileSecret(secretStore, name, {
212
+ provisioningProfileBase64: profileBase64,
213
+ certificateSerialNumbers: info.certificateSerialNumbers.join(','),
214
+ bundleIDs: info.bundleID,
215
+ deviceIDs: info.provisionedDevices.join(','),
216
+ teamID: info.teamID ?? teamId,
217
+ profileName: info.name,
218
+ uuid: info.uuid,
219
+ expirationDate: info.expirationDate,
220
+ });
221
+ log('Apple provisioning profile stored', name);
222
+ return secret;
223
+ }
224
+
225
+ export type ListTeamProfilesInput = {
226
+ relay: AppleRelayWebSocketClient;
227
+ teamId: string;
228
+ };
229
+
230
+ /** Lists the team's development provisioning profiles from the portal. */
231
+ export async function listTeamAppleProfiles({ relay, teamId }: ListTeamProfilesInput) {
232
+ return listAppleProfiles({ relay, teamId, profileKind: 'development' });
233
+ }
234
+
235
+ /** Read a string-ish value from a loosely typed portal record. */
236
+ export function stringField(record: Record<string, unknown> | undefined, key: string) {
237
+ const value = record?.[key];
238
+ if (typeof value === 'string') return value;
239
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
240
+ return undefined;
241
+ }
@@ -99,12 +99,7 @@ export async function startBrowserOwnedAppleIDLogin({
99
99
  finishTwoFactor: async (code) => {
100
100
  const response =
101
101
  twoFactorMethod.type === 'phone' ?
102
- await proxyPhoneTwoFactorCode(
103
- relay,
104
- twoFactorMethod.phoneNumberId,
105
- code,
106
- twoFactorMethod.mode,
107
- )
102
+ await proxyPhoneTwoFactorCode(relay, twoFactorMethod.phoneNumberId, code, twoFactorMethod.mode)
108
103
  : await proxyTwoFactorCode(relay, code);
109
104
  if (response.status < 200 || response.status >= 300) {
110
105
  throw new Error(
@@ -104,10 +104,7 @@ export async function openAppleRelayWebSocket(apiUrl: string, token?: string, or
104
104
  return client;
105
105
  }
106
106
 
107
- export async function proxySrpInit(
108
- relay: AppleRelayWebSocketClient,
109
- payload: AppleSRPInitRequest,
110
- ) {
107
+ export async function proxySrpInit(relay: AppleRelayWebSocketClient, payload: AppleSRPInitRequest) {
111
108
  return relay.request<AppleSRPInitResponse>('srpInit', payload);
112
109
  }
113
110
 
@@ -4,6 +4,7 @@ import type { DeviceHello, DeviceInstallLog, PairRecordPayload } from '../types'
4
4
  import { RelayClient } from './relay-client';
5
5
  import { closeUsbmuxSession, createUsbmuxSession, type UsbmuxSession } from './usbmux';
6
6
  import { claimUsbmux, findUsbmuxCandidates, requestAppleDevice, type UsbmuxCandidate } from './webusb';
7
+ import { errorMessage } from '../../errors';
7
8
 
8
9
  export type DeviceRelayTarget = {
9
10
  device: USBDevice;
@@ -46,7 +47,13 @@ export async function requestUSBAccess({ log }: RequestUSBAccessOptions) {
46
47
  return target;
47
48
  }
48
49
 
49
- export async function startPairingRelay({ registryApiUrl, token, organizationId, log, target }: StartPairingRelayOptions) {
50
+ export async function startPairingRelay({
51
+ registryApiUrl,
52
+ token,
53
+ organizationId,
54
+ log,
55
+ target,
56
+ }: StartPairingRelayOptions) {
50
57
  const deviceRelayUrl = deviceRelayWebSocketUrl(registryApiUrl, token, organizationId);
51
58
  let relay: RelayClient | undefined;
52
59
  try {
@@ -204,10 +211,6 @@ export function deviceRelayWebSocketUrl(registryApiUrl: string, token?: string,
204
211
  return url.toString();
205
212
  }
206
213
 
207
- function errorMessage(error: unknown) {
208
- return error instanceof Error ? error.message : String(error);
209
- }
210
-
211
214
  async function resetUSBDevice(target: DeviceRelayTarget) {
212
215
  if (target.claimedInterfaceNumber !== undefined) {
213
216
  try {
@@ -0,0 +1,65 @@
1
+ import forge from 'node-forge';
2
+ import { describe, expect, test } from 'vitest';
3
+ import { normalizeCertificateSerial, parseProvisioningProfileBase64 } from './browser-storage';
4
+
5
+ function selfSignedCertificateBase64(serialNumber: string) {
6
+ const keys = forge.pki.rsa.generateKeyPair(1024);
7
+ const cert = forge.pki.createCertificate();
8
+ cert.publicKey = keys.publicKey;
9
+ cert.serialNumber = serialNumber;
10
+ cert.validity.notBefore = new Date();
11
+ cert.validity.notAfter = new Date(Date.now() + 24 * 60 * 60 * 1000);
12
+ const attrs = [{ name: 'commonName', value: 'Apple Development Test' }];
13
+ cert.setSubject(attrs);
14
+ cert.setIssuer(attrs);
15
+ cert.sign(keys.privateKey);
16
+ return forge.util.encode64(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes());
17
+ }
18
+
19
+ function profileBase64(certificatesBase64: string[]) {
20
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
21
+ <plist version="1.0">
22
+ <dict>
23
+ <key>Name</key><string>Limrun Dev</string>
24
+ <key>UUID</key><string>11111111-2222-3333-4444-555555555555</string>
25
+ <key>TeamIdentifier</key><array><string>TEAM123</string></array>
26
+ <key>ProvisionedDevices</key><array><string>00008030-000A</string></array>
27
+ <key>DeveloperCertificates</key>
28
+ <array>${certificatesBase64.map((c) => `<data>${c}</data>`).join('')}</array>
29
+ <key>Entitlements</key>
30
+ <dict>
31
+ <key>application-identifier</key><string>TEAM123.com.example.app</string>
32
+ <key>com.apple.developer.team-identifier</key><string>TEAM123</string>
33
+ </dict>
34
+ </dict>
35
+ </plist>`;
36
+ return btoa(xml);
37
+ }
38
+
39
+ describe('parseProvisioningProfileBase64', () => {
40
+ test('extracts embedded certificate serial numbers in normalized form', () => {
41
+ const parsed = parseProvisioningProfileBase64(
42
+ profileBase64([selfSignedCertificateBase64('00ab12cd'), selfSignedCertificateBase64('7f')]),
43
+ );
44
+ expect(parsed.certificateSerialNumbers).toEqual(['AB12CD', '7F']);
45
+ expect(parsed.bundleID).toBe('com.example.app');
46
+ expect(parsed.teamID).toBe('TEAM123');
47
+ expect(parsed.provisionedDevices).toEqual(['00008030-000A']);
48
+ });
49
+
50
+ test('skips unparseable certificates instead of failing the profile', () => {
51
+ const parsed = parseProvisioningProfileBase64(
52
+ profileBase64([btoa('not a certificate'), selfSignedCertificateBase64('1a')]),
53
+ );
54
+ expect(parsed.certificateSerialNumbers).toEqual(['1A']);
55
+ });
56
+ });
57
+
58
+ describe('normalizeCertificateSerial', () => {
59
+ test('uppercases and strips leading zeros', () => {
60
+ expect(normalizeCertificateSerial('00ab12')).toBe('AB12');
61
+ expect(normalizeCertificateSerial('AB12')).toBe('AB12');
62
+ expect(normalizeCertificateSerial('')).toBeUndefined();
63
+ expect(normalizeCertificateSerial(undefined)).toBeUndefined();
64
+ });
65
+ });
@@ -1,3 +1,4 @@
1
+ import forge from 'node-forge';
1
2
  import type {
2
3
  DeviceInstallSigningMode,
3
4
  ProvisioningProfileInfo,
@@ -198,11 +199,43 @@ export function parseProvisioningProfileBytes(bytes: Uint8Array) {
198
199
  applicationIdentifier,
199
200
  bundleID,
200
201
  provisionedDevices: stringArrayValue(value.ProvisionedDevices),
202
+ certificateSerialNumbers: certificateSerialNumbers(stringArrayValue(value.DeveloperCertificates)),
201
203
  getTaskAllow: booleanValue(entitlements['get-task-allow']),
202
204
  expirationDate: stringValue(value.ExpirationDate),
203
205
  } satisfies ProvisioningProfileInfo;
204
206
  }
205
207
 
208
+ /**
209
+ * Normalizes a certificate serial number for comparison: uppercase hex
210
+ * without leading zeros, the format Apple's portal reports in serialNum.
211
+ */
212
+ export function normalizeCertificateSerial(serial?: string) {
213
+ const normalized = (serial ?? '').replace(/^0+/, '').toUpperCase();
214
+ return normalized || undefined;
215
+ }
216
+
217
+ /**
218
+ * Reads the serial numbers of the DER certificates embedded in a profile's
219
+ * DeveloperCertificates array. Unparseable entries are skipped: a profile
220
+ * with an exotic certificate should degrade to weaker filtering, not fail
221
+ * the whole parse.
222
+ */
223
+ function certificateSerialNumbers(developerCertificatesBase64: string[]) {
224
+ const serials: string[] = [];
225
+ for (const entry of developerCertificatesBase64) {
226
+ try {
227
+ // Plist <data> payloads wrap base64 across lines; atob rejects whitespace.
228
+ const der = atob(entry.replace(/\s+/g, ''));
229
+ const certificate = forge.pki.certificateFromAsn1(forge.asn1.fromDer(forge.util.createBuffer(der)));
230
+ const serial = normalizeCertificateSerial(certificate.serialNumber);
231
+ if (serial && !serials.includes(serial)) serials.push(serial);
232
+ } catch {
233
+ // Skip certificates forge cannot parse.
234
+ }
235
+ }
236
+ return serials;
237
+ }
238
+
206
239
  async function getSigningAssetsByID(id?: string) {
207
240
  if (!id) return undefined;
208
241
  const db = await openDB(SIGNING_DB_NAME, SIGNING_DB_VERSION, SIGNING_STORE_NAME, 'id');
@@ -1 +1,2 @@
1
1
  export * from './browser-storage';
2
+ export * from './secret-store';