@microi.net/cli 6.1.7 → 6.1.8

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.
@@ -569,12 +569,394 @@ function installDatePrototypeCompat() {
569
569
  }
570
570
  }
571
571
 
572
+ // BEGIN GENERATED PUBLIC RESOURCE ROUTER; source SHA256 d90207991f19b46429dc21a654e2915e1b4fd6e96f191cede20485d823f3a975
573
+ const createSdkPublicResourceClient = (() => {
574
+ // 公有资源路由的纯校验器与实际消费者桥接;私有分支继续走既有授权接口。
575
+ const HASH = /^[a-f0-9]{64}$/;
576
+ const IDENTIFIER = /^[a-zA-Z0-9_-]{1,64}$/;
577
+ const BLOCKED = /^(?:\.git|\.env|private|private-source|privatesource|source|_origin)$/i;
578
+
579
+ function key(value) {
580
+ if (typeof value !== 'string' || !value || value.length > 1000 || /[\\%?#:\u0000-\u001f\u007f]/.test(value)
581
+ || value.split('/').some(part => !part || part === '.' || part === '..' || BLOCKED.test(part)) || /\.map$/i.test(value)) throw Error('InvalidPublicObjectKey');
582
+ encodeURIComponent(value); // Reject malformed UTF-16 rather than changing the key.
583
+ return value;
584
+ }
585
+ const encodeKey = value => value.split('/').map(encodeURIComponent).join('/');
586
+ function plainBase(value, originOnly = false) {
587
+ if (typeof value !== 'string' || /[\\%?#@\u0000-\u0020\u007f]/.test(value)) throw Error('InvalidPublicBinding');
588
+ const parsed = new URL(value);
589
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash
590
+ || (originOnly && parsed.pathname !== '/')) throw Error('InvalidPublicBinding');
591
+ return value.replace(/\/+$/, '');
592
+ }
593
+ function bindingSnapshot(binding) {
594
+ if (!binding || !IDENTIFIER.test(binding.tenant) || !IDENTIFIER.test(binding.storageOwnerTenant)
595
+ || !HASH.test(binding.targetConfigHash) || !/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(binding.publicBucket)
596
+ || binding.publicBucket.includes('..') || binding.publicBucket === binding.privateBucket) throw Error('InvalidPublicBinding');
597
+ const value = { tenant: binding.tenant, storageOwnerTenant: binding.storageOwnerTenant.toLowerCase(),
598
+ primaryBase: plainBase(binding.primaryBase), backupOrigin: plainBase(binding.backupOrigin, true),
599
+ publicBucket: binding.publicBucket, targetConfigHash: binding.targetConfigHash,
600
+ sourceMappings: (binding.sourceMappings || []).map(item => ({ origin: plainBase(item.origin, true),
601
+ pathPrefix: item.pathPrefix ? key(item.pathPrefix.replace(/\/+$/, '')) + '/' : '' })) };
602
+ if (!value.sourceMappings.length || value.sourceMappings.length > 20) throw Error('InvalidPublicBinding');
603
+ return { ...value, identity: JSON.stringify(value) };
604
+ }
605
+ function legacyPublicFallback(input, binding) {
606
+ if (!input || input.private === true || typeof input.path !== 'string') return '';
607
+ const path = input.path;
608
+ try {
609
+ if (/^https?:/i.test(path)) {
610
+ const url = new URL(path);
611
+ return !/[\\\u0000-\u0020\u007f]/.test(path) && !url.username && !url.password ? path : '';
612
+ }
613
+ if (path.startsWith('//') || path.startsWith('.')) return '';
614
+ if (/[\\\u0000-\u001f\u007f]/.test(path)) return '';
615
+ const relative = path.startsWith('/') ? path.slice(1) : path;
616
+ const decoratedAt = relative.search(/[?#]/);
617
+ const rawKey = decoratedAt < 0 ? relative : relative.slice(0, decoratedAt);
618
+ const suffix = decoratedAt < 0 ? '' : relative.slice(decoratedAt);
619
+ const objectKey = key(rawKey.split('/').map(part => decodeURIComponent(part)).join('/'));
620
+ return plainBase(binding?.primaryBase) + '/' + encodeKey(objectKey) + suffix;
621
+ } catch { return ''; }
622
+ }
623
+ function identify(input, binding) {
624
+ if (!input || input.private === true) return { input, accepted: false, reason: 'PrivateRoutingNotSupported', primaryUrl: '' };
625
+ const path = input.path;
626
+ try {
627
+ if (typeof path !== 'string' || !path) throw Error('InvalidInput');
628
+ if (/^https?:/i.test(path)) {
629
+ if (/[\\?#@\u0000-\u001f\u007f]/.test(path)) throw Error('SignedOrDecoratedUrlNotEligible');
630
+ const parsed = new URL(path);
631
+ const mapping = binding.sourceMappings.find(value => new URL(value.origin).origin === parsed.origin);
632
+ if (!mapping || parsed.protocol !== 'https:') throw Error('UnmanagedOrigin');
633
+ // Check raw path before URL() can normalize dot segments.
634
+ const begin = path.indexOf('/', path.indexOf('://') + 3);
635
+ if (begin < 0) throw Error('ObjectKeyMissing');
636
+ const objectKey = key(path.slice(begin + 1).split('/').map(part => {
637
+ const decoded = decodeURIComponent(part);
638
+ if (/[\/%\\?#:\u0000-\u001f\u007f]/.test(decoded)) throw Error('InvalidEncodedPath');
639
+ return decoded;
640
+ }).join('/'));
641
+ if (!objectKey.startsWith(mapping.pathPrefix)) throw Error('BucketPrefixMismatch');
642
+ return { accepted: true, objectKey: key(objectKey.slice(mapping.pathPrefix.length)), primaryUrl: path };
643
+ }
644
+ // Protocol-relative, traversal and double-slash paths are never interpreted
645
+ // as the configured public bucket. A single leading slash is legacy input.
646
+ if (path.startsWith('//') || path.startsWith('.')) throw Error('UnmanagedRelativePath');
647
+ const objectKey = key(path.startsWith('/') ? path.slice(1) : path);
648
+ return { accepted: true, objectKey, primaryUrl: binding.primaryBase + '/' + encodeKey(objectKey) };
649
+ } catch (error) { return { accepted: false, reason: error.message, primaryUrl: legacyPublicFallback(input, binding) }; }
650
+ }
651
+ function original(item, reason) {
652
+ return Object.freeze({ useBackup: false, url: item.primaryUrl, primaryUrl: item.primaryUrl,
653
+ objectKey: item.objectKey || null, reason });
654
+ }
655
+ function acceptedRoute(row, item, binding, time) {
656
+ if (!row || row.UseBackup !== true || row.ObjectKey !== item.objectKey || !HASH.test(row.SourceRevision)
657
+ || !HASH.test(row.ResourceId) || row.TargetConfigHash !== binding.targetConfigHash
658
+ || !Number.isSafeInteger(row.ExpiresAtUtc) || row.ExpiresAtUtc * 1000 <= time
659
+ || row.ExpiresAtUtc * 1000 > time + 300000 || typeof row.Url !== 'string' || /[?#@\\]/.test(row.Url)) return null;
660
+ const prefix = binding.backupOrigin + '/' + binding.publicBucket + '/_microi-hotspot/public/v3/'
661
+ + binding.storageOwnerTenant + '/' + row.ResourceId + '/' + row.SourceRevision + '/';
662
+ if (!row.Url.startsWith(prefix)) return null;
663
+ const rest = row.Url.slice(prefix.length), slash = rest.indexOf('/');
664
+ if (!/^[a-f0-9]{32}$/.test(rest.slice(0, slash)) || rest.slice(slash + 1) !== encodeKey(item.objectKey)) return null;
665
+ return Object.freeze({ useBackup: true, url: row.Url, primaryUrl: item.primaryUrl, objectKey: item.objectKey,
666
+ expiresAt: row.ExpiresAtUtc * 1000, reason: 'VerifiedPublicReplica' });
667
+ }
668
+
669
+ function createPublicResourceRouter({ getBinding, resolveBatch, now = Date.now, maxEntries = 512, timeoutMs = 5000 }) {
670
+ if (typeof getBinding !== 'function' || typeof resolveBatch !== 'function' || !Number.isInteger(maxEntries)
671
+ || maxEntries < 1 || maxEntries > 10000 || !Number.isInteger(timeoutMs)
672
+ || timeoutMs < 1 || timeoutMs > 30000) throw Error('InvalidRouterDependencies');
673
+ const cache = new Map(), pending = new Map(), cooldown = new Map();
674
+ const cacheKey = (binding, item) => binding.identity + '\n' + item.objectKey;
675
+ const put = (map, id, value) => { map.delete(id); map.set(id, value); while (map.size > maxEntries) map.delete(map.keys().next().value); };
676
+ function cached(binding, item) {
677
+ const id = cacheKey(binding, item), time = now();
678
+ if ((cooldown.get(id) || 0) > time) return original(item, 'BackupFailureCooldown');
679
+ const hit = cache.get(id);
680
+ if (hit && hit.until > time) return hit.route.useBackup ? Object.freeze({ ...hit.route, primaryUrl: item.primaryUrl }) : original(item, hit.route.reason);
681
+ cache.delete(id); cooldown.delete(id); return null;
682
+ }
683
+ function snapshot() { return bindingSnapshot(getBinding()); }
684
+ function fallback(input) { try { return legacyPublicFallback(input, getBinding()); } catch { return legacyPublicFallback(input, null); } }
685
+
686
+ async function resolveMany(inputs) {
687
+ if (!Array.isArray(inputs) || inputs.length > 100) throw Error('ResourceBatchLimitExceeded');
688
+ let binding;
689
+ try { binding = snapshot(); }
690
+ catch { return inputs.map(item => original({ primaryUrl: fallback(item) }, 'BackupBindingUnavailable')); }
691
+ const work = [], created = [];
692
+ const tasks = inputs.map(input => {
693
+ const item = identify(input, binding);
694
+ if (!item.accepted) return Promise.resolve(original(item, item.reason));
695
+ const known = cached(binding, item); if (known) return Promise.resolve(known);
696
+ const id = cacheKey(binding, item);
697
+ if (pending.has(id)) return pending.get(id).then(route => Object.freeze({ ...route, primaryUrl: item.primaryUrl,
698
+ url: route.useBackup ? route.url : item.primaryUrl }));
699
+ let done; const promise = new Promise(resolve => { done = resolve; });
700
+ pending.set(id, promise); work.push(item); created.push({ item, id, done, promise }); return promise;
701
+ });
702
+ if (work.length) {
703
+ // Do not pass secrets, arbitrary URLs or a caller-selected storage endpoint.
704
+ const controller = new AbortController(); let timer;
705
+ Promise.race([
706
+ Promise.resolve().then(() => resolveBatch({ ObjectKeys: work.map(item => item.objectKey), OsClient: binding.tenant }, { signal: controller.signal })),
707
+ new Promise((_, reject) => { timer = setTimeout(() => reject(Error('ResolverTimeout')), timeoutMs); })
708
+ ])
709
+ .then(response => {
710
+ let current; try { current = snapshot(); } catch { current = null; }
711
+ const data = response?.Code === 1 ? response.Data : null;
712
+ const valid = current?.identity === binding.identity && data?.Protocol === 'GenericPublicResourceRoutes/1'
713
+ && data.TargetConfigHash === binding.targetConfigHash && Array.isArray(data.Routes) && data.Routes.length <= 100;
714
+ const rows = new Map(), duplicates = new Set();
715
+ if (valid) for (const row of data.Routes) {
716
+ if (rows.has(row?.ObjectKey)) duplicates.add(row.ObjectKey);
717
+ rows.set(row?.ObjectKey, row);
718
+ }
719
+ for (const job of created) {
720
+ const time = now();
721
+ const route = valid && !duplicates.has(job.item.objectKey)
722
+ ? acceptedRoute(rows.get(job.item.objectKey), job.item, binding, time) : null;
723
+ const selected = (cooldown.get(job.id) || 0) > time ? original(job.item, 'BackupFailureCooldown')
724
+ : route || original(job.item, current?.identity !== binding.identity ? 'TenantOrBindingChanged' : 'VerifiedRouteUnavailable');
725
+ put(cache, job.id, { route: selected, until: selected.useBackup ? selected.expiresAt : time + 15000 });
726
+ job.done(selected);
727
+ }
728
+ })
729
+ .catch(() => {
730
+ for (const job of created) { const route = original(job.item, 'ResolverUnavailable');
731
+ put(cache, job.id, { route, until: now() + 15000 }); job.done(route); }
732
+ })
733
+ .finally(() => { clearTimeout(timer); controller.abort();
734
+ for (const job of created) if (pending.get(job.id) === job.promise) pending.delete(job.id); });
735
+ }
736
+ return Promise.all(tasks);
737
+ }
738
+ return Object.freeze({
739
+ resolveMany,
740
+ async resolve(input) { return (await resolveMany([input]))[0]; },
741
+ // Sync compatibility is cache-only. Callers must pre-resolve before assigning
742
+ // src/href if their FIRST request must avoid the original CDN/OSS endpoint.
743
+ peek(input) {
744
+ try { const binding = snapshot(), item = identify(input, binding);
745
+ return item.accepted ? cached(binding, item) || original(item, 'NotResolvedYet') : original(item, item.reason); }
746
+ catch { return original({ primaryUrl: fallback(input) }, 'BackupBindingUnavailable'); }
747
+ },
748
+ reportFailure(input) {
749
+ try { const binding = snapshot(), item = identify(input, binding);
750
+ if (!item.accepted) return original(item, item.reason);
751
+ const id = cacheKey(binding, item); cache.delete(id); put(cooldown, id, now() + 30000);
752
+ return original(item, 'BackupFailureCooldown'); }
753
+ catch { return original({ primaryUrl: fallback(input) }, 'BackupBindingUnavailable'); }
754
+ },
755
+ clear() { cache.clear(); cooldown.clear(); }
756
+ });
757
+ }
758
+
759
+ /**
760
+ * DiyCommon 与真实批量解析器的桥接。配置只从当前租户的原生 Binding 回读,
761
+ * 不将 SaaS 私桶名/凭据塞入前端。首次 src 赋值必须等待 resolve。
762
+ */
763
+ function createExactAliasRouter({ getBinding, post, now }) {
764
+ const cache = new Map(), pending = new Map(), cooldown = new Map(); let queue = [], scheduled = false;
765
+ const put = (map, id, value) => { map.delete(id); map.set(id, value); while (map.size > 512) map.delete(map.keys().next().value); };
766
+ function pick(input) {
767
+ try {
768
+ const raw = getBinding(), b = bindingSnapshot(raw);
769
+ if (input?.private !== false || raw.aliasUrlRoutingProtocol !== 'PublicSourceUrlRoutes/1'
770
+ || typeof input.path !== 'string' || !input.path.startsWith('https://') || /[\\?#@\u0000-\u0020\u007f]/.test(input.path)) return null;
771
+ const origin = new URL(input.path).origin;
772
+ if (!raw.aliasCandidates.includes(origin)) return null;
773
+ // Only parse the exact key here. Candidate membership is not authorization:
774
+ // the server must revalidate this complete SourceUrl before returning NAS.
775
+ const item = identify(input, { ...b, sourceMappings: [{ origin, pathPrefix: '' }] });
776
+ return item.accepted ? { ...item, input, origin, binding: b, id: b.identity + '\nURL:' + input.path } : null;
777
+ } catch { return null; }
778
+ }
779
+ async function flush() {
780
+ scheduled = false; const jobs = queue; queue = [];
781
+ while (jobs.length) {
782
+ const origins = new Set(), batch = [];
783
+ const identity = jobs[0].item.binding.identity;
784
+ for (let index = 0; index < jobs.length && batch.length < 100;) {
785
+ const job = jobs[index];
786
+ if (job.item.binding.identity !== identity) { index++; continue; }
787
+ if (!origins.has(job.item.origin) && origins.size === 2) { index++; continue; }
788
+ origins.add(job.item.origin); batch.push(...jobs.splice(index, 1));
789
+ }
790
+ let response, timer;
791
+ try { response = await Promise.race([post({ PublicUrls: batch.map(x => x.item.input.path), OsClient: batch[0].item.binding.tenant }),
792
+ new Promise((_, reject) => { timer = setTimeout(() => reject(Error('AliasResolverTimeout')), 5000); })]); }
793
+ catch { response = null; }
794
+ finally { clearTimeout(timer); }
795
+ for (const job of batch) {
796
+ const item = job.item, current = pick(item.input), time = now(), data = response?.Code === 1 ? response.Data : null;
797
+ const rows = data?.Protocol === 'PublicSourceUrlRoutes/1' && data.TargetConfigHash === item.binding.targetConfigHash
798
+ && Array.isArray(data.Routes) && data.Routes.length <= 100
799
+ ? data.Routes.filter(x => x && typeof x === 'object' && !Array.isArray(x) && x.SourceUrl === item.input.path) : [];
800
+ const row = rows.length === 1 ? rows[0] : null;
801
+ const accepted = current?.id === item.id && row?.SourceOrigin === item.origin && HASH.test(row.SourceRoutingFingerprint)
802
+ && row.ExpiresAtUtc * 1000 <= time + 60000 ? acceptedRoute(row, item, item.binding, time) : null;
803
+ const route = (cooldown.get(item.id) || 0) > time ? original(item, 'BackupFailureCooldown')
804
+ : accepted || original(item, 'VerifiedAliasRouteUnavailable');
805
+ put(cache, item.id, { route, until: route.useBackup ? route.expiresAt : time + 15000 });
806
+ pending.delete(item.id); job.done(route);
807
+ }
808
+ }
809
+ }
810
+ return {
811
+ eligible: input => !!pick(input),
812
+ async resolve(input) {
813
+ const item = pick(input); if (!item) return null;
814
+ if ((cooldown.get(item.id) || 0) > now()) return original(item, 'BackupFailureCooldown');
815
+ const old = cache.get(item.id); if (old && old.until > now()) return old.route;
816
+ if (pending.has(item.id)) return pending.get(item.id);
817
+ let done; const result = new Promise(resolve => { done = resolve; }); pending.set(item.id, result);
818
+ queue.push({ item, done }); if (!scheduled) { scheduled = true; queueMicrotask(flush); } return result;
819
+ },
820
+ reportFailure(input) { const item = pick(input); if (!item) return null;
821
+ cache.delete(item.id); put(cooldown, item.id, now() + 30000); return original(item, 'BackupFailureCooldown'); },
822
+ clear() { cache.clear(); cooldown.clear(); }
823
+ };
824
+ }
825
+
826
+ function createPublicResourceClient({ getContext, post, now = Date.now, fetchHead = globalThis.fetch }) {
827
+ let active = '', binding = null, bootstrap = null, expires = 0, generation = 0;
828
+ let queue = [], scheduled = false;
829
+ const context = () => {
830
+ const value = getContext() || {};
831
+ return { tenant: value.tenant, primaryBase: value.primaryBase?.replace(/\/+$/, ''),
832
+ backupOrigin: value.backupOrigin?.replace(/\/+$/, '') };
833
+ };
834
+ const signature = value => JSON.stringify(value);
835
+ const router = createPublicResourceRouter({ getBinding: () => binding || context(), resolveBatch: data => post(data), now });
836
+ const aliases = createExactAliasRouter({ getBinding: () => binding, post, now });
837
+ const fallback = input => Object.freeze({ useBackup: false,
838
+ url: legacyPublicFallback(input, context()), primaryUrl: legacyPublicFallback(input, context()), reason: 'BindingUnavailable' });
839
+ async function ensureBinding() {
840
+ const current = context(), id = signature(current);
841
+ if (active !== id) { active = id; binding = null; bootstrap = null; expires = 0; generation++; router.clear(); aliases.clear(); }
842
+ if (binding && expires > now()) return true;
843
+ if (!binding && expires > now()) return false;
844
+ if (bootstrap) return bootstrap;
845
+ if (!current.backupOrigin || !IDENTIFIER.test(current.tenant || '')) return false;
846
+ const revision = generation;
847
+ const run = (async () => {
848
+ let timer;
849
+ try {
850
+ const response = await Promise.race([post({ ObjectKeys: [], OsClient: current.tenant }),
851
+ new Promise((_, reject) => { timer = setTimeout(() => reject(Error('BindingTimeout')), 5000); })]);
852
+ const data = response?.Data, source = data?.Binding;
853
+ if (generation !== revision || signature(context()) !== id || response?.Code !== 1 || data?.Protocol !== 'GenericPublicResourceRoutes/1' || !source
854
+ || source.PrimaryBase?.replace(/\/+$/, '') !== current.primaryBase
855
+ || source.BackupOrigin?.replace(/\/+$/, '') !== current.backupOrigin
856
+ || source.TargetConfigHash !== data.TargetConfigHash) return false;
857
+ const candidate = { tenant: current.tenant, storageOwnerTenant: source.StorageOwnerTenant,
858
+ primaryBase: source.PrimaryBase, backupOrigin: source.BackupOrigin, publicBucket: source.PublicBucket,
859
+ privateBucket: '', targetConfigHash: source.TargetConfigHash,
860
+ aliasUrlRoutingProtocol: source.AliasUrlRoutingProtocol,
861
+ aliasCandidates: source.AliasUrlRoutingProtocol === 'PublicSourceUrlRoutes/1'
862
+ ? (source.AliasCandidates || []).map(row => plainBase(row.Origin, true)) : [],
863
+ sourceMappings: (source.SourceMappings || []).map(row => ({ origin: row.Origin, pathPrefix: row.PathPrefix || '' })) };
864
+ if (candidate.aliasCandidates.length > 50 || new Set(candidate.aliasCandidates).size !== candidate.aliasCandidates.length) return false;
865
+ bindingSnapshot(candidate);
866
+ binding = candidate; expires = now() + 60000; return true;
867
+ } catch { return false; }
868
+ finally { clearTimeout(timer); }
869
+ })();
870
+ bootstrap = run;
871
+ try { const ready = await run;
872
+ if (!ready && revision === generation && signature(context()) === id) { binding = null; expires = now() + 15000; }
873
+ return ready;
874
+ } finally { if (bootstrap === run) bootstrap = null; }
875
+ }
876
+ async function flush() {
877
+ scheduled = false; const jobs = queue; queue = [];
878
+ for (let start = 0; start < jobs.length; start += 100) {
879
+ const slice = jobs.slice(start, start + 100);
880
+ try { const routes = await router.resolveMany(slice.map(job => job.input));
881
+ slice.forEach((job, index) => job.done(signature(context()) === job.context ? routes[index] : fallback(job.input))); }
882
+ catch { slice.forEach(job => job.done(fallback(job.input))); }
883
+ }
884
+ }
885
+ async function resolve(input) {
886
+ // 只有明确公有输入才触发任何匿名解析;旧 Limit 缺失由调用者保持原兼容分支。
887
+ if (!input || input.private !== false) return Object.freeze({ useBackup: false, url: '', primaryUrl: '', reason: 'ExplicitPublicRequired' });
888
+ const id = signature(context());
889
+ if (!await ensureBinding() || id !== signature(context())) return fallback(input);
890
+ if (aliases.eligible(input)) {
891
+ const revision = generation, route = await aliases.resolve(input);
892
+ return revision === generation && id === signature(context()) ? route || fallback(input) : fallback(input);
893
+ }
894
+ return await new Promise(done => { queue.push({ input, done, context: id });
895
+ if (!scheduled) { scheduled = true; queueMicrotask(flush); } });
896
+ }
897
+ const reportFailure = input => input?.private === false ? aliases.reportFailure(input) || router.reportFailure(input) : fallback({ private: true });
898
+ return Object.freeze({ resolve,
899
+ resolveMany(inputs) { return Promise.all(inputs.map(resolve)); },
900
+ reportFailure,
901
+ async resolveForNavigation(input) {
902
+ const id = signature(context()), route = await resolve(input);
903
+ if (id !== signature(context())) return fallback(input);
904
+ if (!route.useBackup) return route;
905
+ const revision = generation;
906
+ if (!(route.expiresAt > now())) return fallback(input);
907
+ // 新窗口无法监听跨域导航失败。打开前只做一次有界 HEAD,失败回原地址,不循环重试。
908
+ const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 3000);
909
+ try {
910
+ const response = await fetchHead(route.url, { method: 'HEAD', credentials: 'omit', redirect: 'error', signal: controller.signal });
911
+ if (id !== signature(context()) || revision !== generation || !(route.expiresAt > now())) return fallback(input);
912
+ return response.ok ? route : reportFailure(input);
913
+ } catch { return reportFailure(input); }
914
+ finally { clearTimeout(timer); }
915
+ },
916
+ clear() { generation++; binding = null; bootstrap = null; expires = 0; router.clear(); aliases.clear(); }
917
+ });
918
+ }
919
+
920
+ // 历史 Limit 缺失不是公开声明,不能据此新增匿名 NAS 访问。
921
+ function isExplicitPublicResource(metadata, configuredLimit) {
922
+ return metadata && typeof metadata === 'object' && Object.hasOwn(metadata, 'Limit')
923
+ ? metadata.Limit === false : configuredLimit === false;
924
+ }
925
+
926
+ /** 表单实例、租户、记录、字段及文件身份共同限定异步地址赋值。 */
927
+ function createPublicResourceAssignmentGuard({ getContext, contains }) {
928
+ const pending = new Map(); let disposed = false;
929
+ const current = ticket => {
930
+ if (disposed || pending.get(ticket.key) !== ticket) return false;
931
+ const context = getContext();
932
+ return Object.keys(ticket.context).every(name => context[name] === ticket.context[name])
933
+ && contains(ticket.path, ticket.fileId);
934
+ };
935
+ return Object.freeze({
936
+ begin(key, path, fileId = null) {
937
+ const ticket = { key, path, fileId, context: { ...getContext() }, route: null, fallbackUsed: false };
938
+ pending.set(key, ticket); return ticket;
939
+ },
940
+ current,
941
+ get(key) { return pending.get(key); },
942
+ tickets() { return [...pending.values()]; },
943
+ invalidate(key) { pending.delete(key); },
944
+ clear() { pending.clear(); },
945
+ dispose() { disposed = true; pending.clear(); }
946
+ });
947
+ }
948
+
949
+ return createPublicResourceClient;
950
+ })();
951
+ // END GENERATED PUBLIC RESOURCE ROUTER
952
+
572
953
  export function createMicroiV8(options = {}) {
573
954
  // 运行时配置可通过 createMicroiV8(options) 或 client.configure(next) 覆盖。
574
955
  let config = {
575
956
  apiBase: '',
576
957
  webBase: '',
577
958
  fileServer: '',
959
+ fileServerBak: '',
578
960
  osClient: '',
579
961
  token: '',
580
962
  clientType: getUni() ? 'Mobile' : 'PC',
@@ -606,6 +988,9 @@ export function createMicroiV8(options = {}) {
606
988
 
607
989
  // 更新配置后立即刷新并发队列,保证 maxConcurrent 热更新生效。
608
990
  function configure(next = {}) {
991
+ if (['apiBase','osClient','fileServer','fileServerBak'].some(key => Object.prototype.hasOwnProperty.call(next,key) && next[key] !== config[key])) {
992
+ sdkPublicResourceEpoch++; sdkPublicResourceClient?.clear();
993
+ }
609
994
  config = { ...config, ...next };
610
995
  if (Object.prototype.hasOwnProperty.call(next, 'maxConcurrent')) {
611
996
  runQueued = createQueue(config.maxConcurrent);
@@ -961,6 +1346,63 @@ export function createMicroiV8(options = {}) {
961
1346
  }
962
1347
 
963
1348
  // 资源地址统一过滤占位图,并兼容 HDFS 私有文件、FileServer 和绝对地址。
1349
+ // Public media selection is asynchronous. assetUrl remains a synchronous legacy API.
1350
+ let sdkPublicResourceClient = null;
1351
+ let sdkPublicResourceEpoch = 0;
1352
+ function sdkPublicMetadata(value, path, depth = 0) {
1353
+ if (depth > 8 || value == null) return null;
1354
+ if (typeof value === 'string' && /^[\[{]/.test(value.trim())) {
1355
+ try { return sdkPublicMetadata(JSON.parse(value), path, depth + 1); } catch { return null; }
1356
+ }
1357
+ if (Array.isArray(value)) {
1358
+ for (const entry of value) { const selected = sdkPublicMetadata(entry, path, depth + 1); if (selected) return selected; }
1359
+ return null;
1360
+ }
1361
+ return typeof value === 'object' && extractUploadPath(value) === path ? value : null;
1362
+ }
1363
+ function sdkPublicInputAllowed(value, options = {}) {
1364
+ const path = extractUploadPath(value), meta = sdkPublicMetadata(value, path);
1365
+ if (options.private === true || (meta && (
1366
+ (Object.prototype.hasOwnProperty.call(meta, 'Limit') && meta.Limit !== false)
1367
+ || meta.Private === true || meta.IsPrivate === true || meta.Public === false))) return false;
1368
+ return options.private === false || meta?.Limit === false || meta?.Public === true;
1369
+ }
1370
+ function sdkPublicContext() {
1371
+ return { tenant: config.osClient, primaryBase: config.fileServer || '',
1372
+ backupOrigin: config.fileServerBak || client.SysConfig?.FileServerBak || '' };
1373
+ }
1374
+ function sdkPublicIdentity() {
1375
+ return JSON.stringify([sdkPublicResourceEpoch, config.apiBase, sdkPublicContext()]);
1376
+ }
1377
+ function getSdkPublicResourceClient() {
1378
+ if (!sdkPublicResourceClient) sdkPublicResourceClient = createSdkPublicResourceClient({
1379
+ getContext: sdkPublicContext,
1380
+ post: data => apiEngineRun('mci-file-resource-public-url', data, { checkCode: false, silentError: true, timeout: 5000 })
1381
+ });
1382
+ return sdkPublicResourceClient;
1383
+ }
1384
+ async function resolvePublicResource(value, options = {}) {
1385
+ const path = extractUploadPath(value);
1386
+ if (!path || isBlockedAsset(path) || !sdkPublicInputAllowed(value, options))
1387
+ return { useBackup: false, url: '', primaryUrl: '', reason: 'ExplicitPublicRequired' };
1388
+ // Local files and API-owned protected/runtime endpoints keep their existing transport.
1389
+ if (/^(?:data:|blob:|file:|\.?\/static\/)/i.test(path) || /^\/?(?:file\/|micro-app\/v3(?:\/|$))/i.test(path))
1390
+ return { useBackup: false, url: assetUrl(value), primaryUrl: assetUrl(value), reason: 'LocalOrApiResource' };
1391
+ const identity = sdkPublicIdentity(), publicClient = getSdkPublicResourceClient();
1392
+ const route = options.navigation === true
1393
+ ? await publicClient.resolveForNavigation({ path, private: false })
1394
+ : await publicClient.resolve({ path, private: false });
1395
+ // The portable SDK also binds the API base, which is not a public storage binding field.
1396
+ return identity === sdkPublicIdentity() ? route
1397
+ : { useBackup: false, url: '', primaryUrl: '', reason: 'ClientContextChanged' };
1398
+ }
1399
+ function reportPublicResourceFailure(value, options = {}) {
1400
+ if (!sdkPublicInputAllowed(value, options)) return { useBackup: false, url: '', primaryUrl: '', reason: 'ExplicitPublicRequired' };
1401
+ const path = extractUploadPath(value);
1402
+ return sdkPublicResourceClient?.reportFailure({ path, private: false })
1403
+ || { useBackup: false, url: assetUrl(value), primaryUrl: assetUrl(value), reason: 'PrimaryFallback' };
1404
+ }
1405
+
964
1406
  function assetUrl(value) {
965
1407
  const picked = extractUploadPath(value);
966
1408
  if (!picked || isBlockedAsset(picked)) return '';
@@ -999,6 +1441,7 @@ export function createMicroiV8(options = {}) {
999
1441
  async function resolveFileUrl(filePathName, options = {}) {
1000
1442
  const path = extractUploadPath(filePathName);
1001
1443
  if (!path || isBlockedAsset(path)) return '';
1444
+ if (sdkPublicInputAllowed(filePathName, options)) return (await resolvePublicResource(filePathName, options)).url;
1002
1445
  if (/^(https?:|blob:|data:|file:)/i.test(path)) return assetUrl(path);
1003
1446
  // 文件实际私有标记和私有字段上下文优先,禁止因字段曾改为公有而错误降级 CDN。
1004
1447
  const privateAccess = options.private === true || hasPrivateUploadFlag(filePathName);
@@ -1534,6 +1977,8 @@ export function createMicroiV8(options = {}) {
1534
1977
  setCurrentUser: setUser,
1535
1978
  getCurrentUser: getUser,
1536
1979
  assetUrl,
1980
+ resolvePublicResource,
1981
+ reportPublicResourceFailure,
1537
1982
  sanitizeAssetUrl: assetUrl,
1538
1983
  resolveAssetUrl: assetUrl,
1539
1984
  resolveAvatarUrl: resolveFileUrl,
@@ -0,0 +1,29 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ // Single-file SDKs retain their delivery contract; only this generated router block is synchronized.
7
+ const args = process.argv.slice(2), write = args.includes('--write');
8
+ function option(name, fallback) { const at = args.indexOf(name); return at < 0 ? fallback : args[at + 1] || (() => { throw Error('Missing ' + name); })(); }
9
+ const root = path.resolve(option('--root', path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')));
10
+ const routerPath = path.resolve(option('--router', path.join(root, 'Microi.Client/src/utils/public-resource-router.mjs')));
11
+ const bytes = fs.readFileSync(routerPath), router = bytes.toString('utf8');
12
+ if (/^import\s/m.test(router) || /^export (?!function )/m.test(router)) throw Error('RouterModuleShapeChanged');
13
+ const sha = crypto.createHash('sha256').update(bytes).digest('hex');
14
+ const generated = '// BEGIN GENERATED PUBLIC RESOURCE ROUTER; source SHA256 ' + sha + '\n'
15
+ + 'const createSdkPublicResourceClient = (() => {\n' + router.replace(/^export function /gm, 'function ')
16
+ + '\nreturn createPublicResourceClient;\n})();\n// END GENERATED PUBLIC RESOURCE ROUTER';
17
+ const marker = /\/\/ BEGIN GENERATED PUBLIC RESOURCE ROUTER; source SHA256 [a-f0-9]{64}\r?\n[\s\S]*?\/\/ END GENERATED PUBLIC RESOURCE ROUTER/g;
18
+ const paths = ['microi.skills/microi.v8.js', 'microi.uniapp/src/utils/microi.v8.js', 'Microi.Server/OfficialApplications/PlatformService/src/utils/microi.v8.js'];
19
+ const pending = paths.map(relative => {
20
+ const full = path.join(root, relative), text = fs.readFileSync(full, 'utf8');
21
+ const blocks = [...text.matchAll(marker)]; if (blocks.length !== 1) throw Error('ExpectedSingleRouterBlock:' + relative);
22
+ const nl = text.includes('\r\n') ? '\r\n' : '\n';
23
+ const block = generated.replace(/\r?\n/g, nl);
24
+ return { path: relative, full, text, next: text.replace(marker, () => block) };
25
+ });
26
+ const changed = pending.filter(item => item.text !== item.next);
27
+ if (!write && changed.length) throw Error('SdkRouterOutOfDate:' + changed.map(item => item.path).join(','));
28
+ if (write) for (const item of changed) fs.writeFileSync(item.full, item.next);
29
+ console.log(JSON.stringify({ mode: write ? 'write' : 'check', routerSha256: sha, sdkCount: paths.length, changed: changed.map(item => item.path) }));
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: system-observability
3
- description: Microi 系统日志/监控查询、诊断与治理规范。用于分析系统日志、Trace、热点接口、CPU/内存吃满、OOM、异常分配、接口引擎/V8 事件归因、重启事故恢复、网络流量、安全事件和监控性能成本。
3
+ description: Microi 系统日志/监控查询、诊断与治理规范。用于日志、Trace、CPU/内存/OOM、网络流量、安全事件,以及 OSS/CDN 高下载文件归因、NAS 热点分流与完整日费用验收。
4
4
  ---
5
5
 
6
6
  > **Microi吾码基础规范(强制):** 任何 AI 模型与宿主每次新建或接续吾码任务,先完整读取 `../workspace-conventions/SKILL.md`,必须执行版本播报、`@microi.net/cli` 后台自动升级、Skills/MCP 同步和进度播报。安装与诊断读取 `../microi-codex-installer/SKILL.md`;更新失败延后重试,不阻断当前工作。
@@ -201,6 +201,12 @@ UnblockIp:<ip>
201
201
 
202
202
  ## 接口引擎归因规范
203
203
 
204
+ OSS/CDN 高费用、文件下载榜单和 NAS 分流还须读取
205
+ [文件资源分流操作流程](../v8-file-upload/references/hotspot-offload-operations.md)。
206
+ 通过受管 `mci-cdn-manager` 核对流量来源及账单,通过 `mci-file-resource-runtime`
207
+ 检查资格、复制及实际路由;完整自然日、当天部分账单和一次性复制成本分开记录。
208
+ 已有通用 `microi_run_engine` 复用当前租户鉴权,不另建云凭据或旁路 HTTP 工具。
209
+
204
210
  - PC、UniApp、内置微服务、MCP 和应用包中的固定接口必须调用 `/apiengine/{ApiEngineKey}` 或该引擎唯一 `ApiAddress`;新增代码禁止调用 `/api/ApiEngine/Run`。
205
211
  - SDK 的普通 `ApiEngine.Run` 必须自动生成真实引擎路径;旧通用入口只能封装在显式 `RunLegacy` 中,供不能立即升级的历史客户端使用。
206
212
  - 监控中保留通用入口的兼容识别,但不能依赖读取请求正文才能知道 Key;真实路由、访问日志、限流和流量排行应直接显示接口引擎 Key。
@@ -231,8 +237,16 @@ UnblockIp:<ip>
231
237
 
232
238
  ### CDN 模块
233
239
 
240
+ - 官方 Chromium 分流仅限 `static.itdos.com/openclaw/chromium/` 的 137.0.7151.68 三平台精确 ZIP。先核三份真实 native Ready/raw SHA/大小/SourceRevision/TargetConfigHash 与独立公有回退,再以 `ChromiumRedirectState` 和逐平台 `Preview → Submit` 安装;各平台保留原 RequestKey/Id/ConfigId/RequestId。Unknown 只回读,回滚必须使用本平台原成功操作 Id,禁止同形认领或全域改写。外部云控制台写者不受域租约约束;真实旧 URL 两跳、NAS HEAD/Range 和完整日账单分别验收。
241
+ - 七种官方空库的 `mci-file-resource-empty-database-download` 只映射固定 Database 枚举和公开 `install/microi_empty_*.sql.zip`,当前可信原生副本可用才返回 NAS 与完整性 Header,否则回官方 CDN。它不复制、不签私链、不接收任意 URL。MCP 复用 run/save/官方包发布工具,不新增通用代理。
242
+
243
+ - 回源路径只读诊断使用管理员入口 `mci-cdn-manager` 的固定 `OriginRouting` 动作,仅传 `Provider:aliyun` 与一个精确 `Domain`;先确认 `Capabilities.Providers` 内阿里云 `Features.OriginRouting=true`。旧 `Configs` 五项不变,禁止开放云 Action、FunctionNames、Endpoint、凭据或分页任意覆盖。
244
+ - `ConfigurationReadComplete` 只表示固定配置集合读取完整;缺失、云失败、分页/截断或超限不能解释成零规则。保留规则 `ConfigId/ParentId/Status/Index/sequence`。条件、源站组、EdgeScript/EdgeRoutine 与未知参数作为待审证据,绝不回传脚本、敏感 Header/查询值或原始 IP。
245
+ - 同一 OSS 源不代表同对象路径;`EffectiveObjectMappingVerified/AutomaticAliasRoutingAuthorized` 固定为 false。必须另验 Host、路径、条件关联、当前公开权限及内容身份后才可能配置别名;本动作不复制、不公开、不分流。三条 Managed 引擎经系统日志应用包交付,通用 MCP 已可调用,不为诊断另发 C#、npm 或后端镜像。
246
+
247
+
234
248
  - 现有 `microi-platform-service` 的系统日志页增加 CDN Tab;不另建同名应用。固定管理员入口为 `mci-cdn-manager`,内部 `mci-cdn-core / mci-cdn-aliyun / mci-cdn-worker` 均 `StopHttp=1`,四个引擎归 `app.microi.sys-log` 的 Managed 资源。
235
- - 通过当前租户 MCP 的 `microi_run_engine` 先查 `Capabilities`,再使用 `Domains / Trend / Usage / CacheHit / OriginTraffic / Billing / StorageHealth / StorageLogs / Logs / LogDelivery / ReportList / Reports / Sls / Configs / Config / Rules / Operations`。现有执行、源码、元数据及发布工具已覆盖此模块,不为 CDN 新增旁路鉴权或通用 HTTP 代理。
249
+ - 通过当前租户 MCP 的 `microi_run_engine` 先查 `Capabilities`,再使用 `OriginRouting / Domains / Trend / Usage / CacheHit / OriginTraffic / Billing / StorageHealth / StorageLogs / Logs / LogDelivery / ReportList / Reports / Sls / Configs / Config / Rules / Operations`。现有执行、源码、元数据及发布工具已覆盖此模块,不为 CDN 新增旁路鉴权或通用 HTTP 代理。
236
250
  - 凭据优先级为当前租户 `Integration.Cdn.Aliyun.*` 完整私密组 → `Integration.Dns.Aliyun.*` 私密组 → 当前租户 SaaS 的兼容 DNS 字段。半组配置必须失败,不能混组或回退主租户。新增密钥写 `mci_system_setting` Secret,由可信后端解密到后端 `ServerPrivateSettings`;不返回原文。
237
251
  - 用量与金额分开:`Trend` 带宽点不能按小时积分冒充流量;`Usage(Field=traf)` 的字节量才用于流量合计,金额读取 `Billing`,禁止统一乘固定单价标为实际费用。`Usage` 接受 `UsageType`(不是平台保留参数 `Type`)、`DataProtocol`、`Field`、`Area`;流量/带宽只允许 all 类型与协议,请求数只允许 all 地区。UTC 起止时间与实际 `DataInterval` 必须保留;300/3600/86400 秒的 Usage 单次跨度分别不超过3/7/31天。`CacheHit/OriginTraffic` 同粒度跨度为3/31/366天,不传 Area,单位分别为百分比/字节。
238
252
  - `Billing.Kind` 固定为 `Overview/Instance/Packages/PackageUsage`,返回 `Data.Result`。先用 `BillingCycle=YYYY-MM` 概览发现产品代码,再按 `ProductCode` 查 `Instance` 的 MONTHLY/DAILY 计费项;DAILY 的 BillingDate 必须属于账期。MaxResults不超过300,完整消费NextToken;Packages用Page/PageSize,PackageUsage绑定InstanceId、以中国站账单时间格式原样查询并将NextToken映射LastToken。应付PretaxAmount、原价PretaxGrossAmount、实付PaymentAmount和资源包抵扣不混用;当前包列表不证明历史抵扣,当月暂计、数据延迟、权限失败不伪造为零。
@@ -269,3 +283,5 @@ UnblockIp:<ip>
269
283
  - 对请求热路径做开关前后压测,确认观测开启后 P95/P99、CPU、内存和分配率没有不可接受回退;故障 Mongo 不得阻塞业务请求。
270
284
  - 多节点、网卡重置/回绕、服务重启、匿名/登录、大上传/下载、敏感字段脱敏、时间桶幂等和缓存失效均有验证证据。
271
285
  - 内存专项至少覆盖:V8 限制关闭的复杂执行、真实 Kestrel TraceId、500 个活动请求、异步嵌套归属、独立采集栈、Mongo 故障、执行中强杀、改名重启、跨节点补传、旧快照重放和租户隔离。
286
+
287
+ - 原生签名状态必须先对原始 UTF-8 JSON 字节验 HMAC,再以保留小数精度及字符串日期的设置解析;禁止先解析重序列化后验签,否则 SaaS decimal 阈值 1.000000 变为 1.0 会使后台泵停摆。修复读取器时保留既有回执,不手工改写状态或 Ready。
@@ -1,12 +1,21 @@
1
1
  ---
2
2
  name: v8-file-upload
3
- description: Microi V8 与 MCP 文件上传下载指南。用于处理流式 AI 应用发布、V8.FilesByteBase64、V8.Method.Upload、私有文件 URL、文件响应、HDFS、OSS、MinIO 和 S3 存储。
3
+ description: Microi V8 与 MCP 文件上传下载指南。用于流式发布、私有文件、HDFS/OSS/MinIO/S3,以及 OSS/CDN 费用治理、FileServerBak 热点自动复制与持续 NAS 分流、实际消费验收。
4
4
  ---
5
5
 
6
6
  > **Microi吾码基础规范(强制):** 任何 AI 模型与宿主每次新建或接续吾码任务,先完整读取 `../workspace-conventions/SKILL.md`,必须执行版本播报、`@microi.net/cli` 后台自动升级、Skills/MCP 同步和进度播报。安装与诊断读取 `../microi-codex-installer/SKILL.md`;更新失败延后重试,不阻断当前工作。
7
7
 
8
8
  # Microi V8 文件上传下载
9
9
 
10
+ ## OSS / CDN 费用与 NAS 分流入口
11
+
12
+ 用户要求降低 OSS/CDN 流量费用、把高下载模型/JS/图片/视频转到 NAS,或询问
13
+ “1 GiB / 24 小时配置在哪里”时,使用已有热点分流能力。先读
14
+ [热点分流操作流程](references/hotspot-offload-operations.md),再核对本文的原生权限和依赖边界。
15
+ 系统设置 `/system-config` 的 `FileServerBakThresholdGiB`、`FileServerBakWindowHours`
16
+ 控制初次资格;`FileServerBak` 是备用 HTTPS origin。可信达标后低流量不撤回 NAS 偏好。
17
+ 只复制、保存配置或发布页面都不等于实际入口已走 NAS,也不等于完整日费用已下降。
18
+
10
19
  ## 源码仓库资源隔离(默认强制)
11
20
 
12
21
  截图、配图、PDF/PPT、音视频、压缩包、模型、安装包和其它大体积交付资源统一通过 MCP 上传 CDN,禁止提交到 Gitee/GitHub 源码历史,即使压缩到小于 1 MB 也不能把截图或附件改放源码仓库。官方资源使用已校验的 `microi_itdos`(`https://api.microi.net + OsClient=iTdos`)公有桶与 `https://file.microi.net` 前缀;私有资料按租户实际权限保持私有。源码只存 URL、字节数、SHA-256、MIME 等轻量清单。上传后独立回读实际字节和类型,PDF 验证 Range;超过 Base64 协议容量使用流式/分片工具。暂存区及每个待推送提交都须排除资源本体,详细门禁遵循 [工作区默认 CDN 规范](../workspace-conventions/SKILL.md#截图与大体积资源必须走-cdn默认强制)。
@@ -21,12 +30,9 @@ description: Microi V8 与 MCP 文件上传下载指南。用于处理流式 AI
21
30
 
22
31
  ## MCP stdio 与完整包的传输边界
23
32
 
24
- - 标准 MCP 服务的 stdio 使用固定 `128 MiB`(`134217728` 字节)未解析 JSON-RPC 缓冲,超过边界立即拒绝并关闭连接;不读取动态环境变量放大。SDK 调用端若仍使用
25
- 默认 `10 MiB` 缓冲,大响应仍可能关闭客户端,必须核对调用端支持的真实边界。
26
- - 该限制计算 UTF-8 wire 字节,包含完整 JSON 信封、Base64 和重复 `Data/Result` 正文。单个 Base64 字段的理论原始字节上限约 `96 MiB` 减信封,不能据此保证
27
- 任意 `64 MiB` 包响应都通过,也不能把它当成 HDFS `256 MiB` 应用包能力的新上限。
28
- - 已存在的正规完整包协议仍可使用 `PreparedPersistPackageByteBase64`;先按精确正文计算编码和整个请求/响应字节数,保留版本、CAS、哈希、HDFS 回读及快照 finalize。
29
- 连接关闭属于结果未知,先回读同版本、同请求身份,不能换请求键盲重传。
33
+ - 标准 MCP 服务的 stdio 使用固定 `128 MiB`(`134217728` 字节)未解析 JSON-RPC 缓冲,超过边界立即拒绝并关闭连接;不读取动态环境变量放大。SDK 调用端若仍使用默认 `10 MiB` 缓冲,大响应仍可能关闭客户端,必须核对调用端支持的真实边界。
34
+ - 该限制计算 UTF-8 wire 字节,包含完整 JSON 信封、Base64 和重复 `Data/Result` 正文。单个 Base64 字段的理论原始字节上限约 `96 MiB` 减信封,不能据此保证任意 `64 MiB` 包响应都通过,也不能把它当成 HDFS `256 MiB` 应用包能力的新上限。
35
+ - 已存在的正规完整包协议仍可使用 `PreparedPersistPackageByteBase64`;先按精确正文计算编码和整个请求/响应字节数,保留版本、CAS、哈希、HDFS 回读及快照 finalize。连接关闭属于结果未知,先回读同版本、同请求身份,不能换请求键盲重传。
30
36
  - 超过 wire 能力的源码、运行资产优先使用已有目录/文件 stream 或分片协议及其
31
37
  小型指针/回执,不把私有源码、超大资产常态化塞入 JSON/Jint;支持更大正文的其它
32
38
  连接也须有明确边界。不能用增大stdio缓冲绕过能力鉴权、业务配额或包完整性检查。
@@ -249,27 +255,8 @@ Unity `Data`、WASM、Windows 安装包、视频模型等发布资产不得进
249
255
  - 生产 H5 不能只依赖 `uni.uploadFile`。页面从 `uni.chooseImage` 得到的 `tempFiles[0].file`、`tempFiles[0]`、`blob:` / `data:` 临时路径都要传给 `V8.uploadFile`,并设置 `preferFetch:true`;SDK 必须能用 `fetch + FormData` 兜底,否则线上可能报 `未找到 MicroiV8 上传适配器。`。
250
256
 
251
257
  <!-- /microi-progressive:chunk -->
252
- <!-- microi-progressive:chunk id=v8-file-upload-003 sha256=9d8f1e54ec058d9624a051cc55270019fa1b4a88dadc20f03b2cfa1b856a5bc3 -->
253
- ## 跨平台文件同步登录会话
254
-
255
- 文件柜、文件同步等需要连接另一套 Microi API 的工具,必须把远程平台视为独立登录会话:
256
-
257
- - 用户必须先完成远程登录,登录成功后显示远程用户名称、帐号、ApiBase、OsClient 和登录状态,并提供明确的退出登录操作。
258
- - 历史远程连接通过 `mci_` 前缀表保存,并按 `V8.CurrentUser.Id` 做行级隔离;不得把帐号、密码或 Token 放入 `localStorage`。
259
- - 密码和 Token 只能由受保护的接口引擎写入、读取和清理。数据库必须保存可校验的加密密文,普通 FormEngine 列表不得返回密文字段。
260
- - 密码和 Token 使用 `V8.Method.ProtectApiEngineSecret/UnprotectApiEngineSecret`,由宿主把密文绑定当前 `OsClient + ApiEngineKey`;不得从已脱敏的 `V8.OsClientModel` 读取 `AuthSecret/DbConn`,也不得使用进程级临时密钥。接口引擎 Key 必须稳定,确保服务重启和应用升级后仍能解密历史连接。
261
- - 历史连接列表只返回脱敏元数据;一键重连时再按记录 Id 和当前用户读取凭据。删除连接必须同时清除保存的密码和 Token。
262
- - 远程目标登录后必须调用文件柜能力探针 `mci_file_sync_capability` 检查同步协议版本,并使用其 `Data.FileManagerSysMenuId` 作为目标租户权威文件柜菜单;禁止硬编码发布端菜单 Id。接口不存在、未返回菜单 Id、返回 404/非标准结果或协议版本过低时,提示目标平台更新【文件柜】应用,不得继续同步。
263
- - 验收至少覆盖:登录成功显示身份、退出后 Token 清空、历史连接一键重连、删除连接、密文落库、服务重启后仍可解密、目标平台缺少能力接口时的升级提示。
264
-
265
- ### 热点公开资源的可信分流
266
-
267
- - `PublicResourceOffload/2` 与旧目录/路由第一版分别验收。系统日志应用拥有 `mci-file-resource-runtime`(管理员控制面)与 `mci-file-resource-public-url`(匿名只读公开选择);目录的 Ready、流量和副本行不作为运行授权,不能通过普通 CRUD 制造成功。
268
- - 从当前 SaaS 引擎读取 MinIO 凭据,第二版 FileServerBak 使用 HTTPS origin 加后端绑定的公有桶。主 HDFS 保留 Aliyun;不全桶迁移、不删源文件、不把私有桶改公开。只复制当前已发布公开运行清单,JS/CSS/模型依赖完整成组;复制、签名回读与匿名 SHA/MIME/Range/CORS 全部成功后才允许消费者采用 NAS。
269
- - 完整北京时间自然日的 CDN 运营报表及已接入 SDK 的实际响应字节可证明超过阈值的正向下界;榜单缺行、计量缺失和空报表不是零。迁移验证单独排除;历史人工指定单独记录,不能伪造当前24小时阈值通过。阈值触发后的同版本副本持续保留,避免源流量下降反复切回。
270
- - HTTP/MCP只登记复制意图;共享签名状态、租约栅栏、逐文件回读恢复与退避负责副作用。未知上传先对账,异内容不覆盖。当前版本/完整清单或配置变化、撤为私有、NAS健康失效均回退原地址;先确认真正消费路径,再分别验收实际下载下降与完整日账单节省。主壳预配置 JS、旧绝对 CDN 书签和非受管资源须明确列出未接入范围,不能声称所有资源自动切换。
271
-
272
- <!-- /microi-progressive:chunk -->
258
+ 热点分流与跨平台会话的完整规则见
259
+ [操作与原生边界](references/hotspot-offload-operations.md)。
273
260
  ## 详细参考路由(渐进披露)
274
261
  仅在当前任务涉及对应主题时读取;下列文件合计保留了原 SKILL.md 的全部详细知识。
275
262
  - [references/progressive-01-公有桶-vs-私有桶.md](references/progressive-01-公有桶-vs-私有桶.md):公有桶 vs 私有桶;接口直接响应文件(下载/导出);通过 URL 列表批量下载并入库